공개 본문 인용 편집 분리

This commit is contained in:
2026-07-27 16:39:20 +09:00
parent 9efa6d6af5
commit 1d15a0e4d1
10 changed files with 318 additions and 154 deletions

View File

@@ -16,10 +16,8 @@ import {
} from '../../lib/markdown-live-edit.js'
import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js'
import {
formatQuoteLine,
getQuoteContentStartLine,
getQuoteLineEntries,
normalizeQuoteEditableValue
getQuoteLineEntries
} from '../../lib/markdown-quote.js'
import { getGalleryRows } from '../../lib/content-gallery-images.js'
import { useContentMarkdownLightbox } from '../../composables/useContentMarkdownLightbox.js'
@@ -29,6 +27,7 @@ import { useContentMarkdownLiveSelection } from '../../composables/useContentMar
import { useContentMarkdownFencedBlocks } from '../../composables/useContentMarkdownFencedBlocks.js'
import { useContentMarkdownParagraphs } from '../../composables/useContentMarkdownParagraphs.js'
import { useContentMarkdownLists } from '../../composables/useContentMarkdownLists.js'
import { useContentMarkdownQuotes } from '../../composables/useContentMarkdownQuotes.js'
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
import ProseCodeBlock from './ProseCodeBlock.vue'
import ProseHtml from './ProseHtml.vue'
@@ -199,6 +198,19 @@ const {
insertBelowBlock: (block, options) => onInsertBelowBlock(block, options)
})
const {
onQuoteBlockCommit,
onQuoteBlockInsertBelow,
onQuoteBlockConvertToParagraph
} = useContentMarkdownQuotes({
content: computed(() => props.content),
pendingFocusLine,
pendingFocusPosition,
pendingFocusOffset,
commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines),
insertBelowBlock: (block, options) => onInsertBelowBlock(block, options)
})
/**
* 빈 줄 공백 블록 높이를 반환한다.
* @param {Object} block - 렌더링 블록
@@ -400,16 +412,6 @@ const normalizeInsertBelowPayload = (payload) => {
*/
const getMarkdownLine = (lineIndex) => String(props.content || '').split('\n')[lineIndex] ?? ''
/**
* 블록에 해당하는 원본 마크다운 줄 목록을 반환한다.
* @param {Object} block - 블록
* @returns {string[]} 줄 목록
*/
const getBlockSourceLines = (block) => String(props.content || '').split('\n').slice(
block.meta.startLine,
(block.meta.endLine ?? block.meta.startLine) + 1
)
/**
* 인라인 편집 결과를 마크다운 줄로 반영한다.
* @param {Object} block - 블록
@@ -680,142 +682,6 @@ const onHeadingInsertBelow = (block, payload) => {
commitInlineBlockLines(block, [buildHeadingLine(block.level, value), ''])
}
/**
* 인용 줄 인라인 편집 반영
* @param {Object} block - 인용 블록
* @param {number} lineIndex - 줄 인덱스
* @param {string|{ value: string, raw?: boolean }} payload - 편집 내용
* @returns {void}
*/
const onQuoteLineInlineCommit = (block, lineIndex, payload) => {
const { value, raw } = normalizeCommitPayload(payload)
const sourceLines = String(props.content || '').split('\n').slice(
block.meta.startLine,
(block.meta.endLine ?? block.meta.startLine) + 1
)
const nextLines = [...sourceLines]
nextLines[lineIndex] = formatQuoteLine(value, raw)
commitInlineBlockLines(block, nextLines)
}
/**
* 인용 줄 아래에 새 인용 줄을 삽입한다.
* @param {Object} block - 인용 블록
* @param {number} lineIndex - 줄 인덱스
* @returns {void}
*/
const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
const { value, raw, before, after, caretAtStart } = normalizeInsertBelowPayload(payload)
const nextLines = getBlockSourceLines(block)
if (caretAtStart && after.length) {
nextLines[lineIndex] = formatQuoteLine(after, raw)
nextLines.splice(lineIndex, 0, '> ')
pendingFocusLine.value = block.meta.startLine + lineIndex
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (before.length && after.length) {
nextLines[lineIndex] = formatQuoteLine(before, raw)
nextLines.splice(lineIndex + 1, 0, formatQuoteLine(after, raw))
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
return
}
if (lineIndex < nextLines.length) {
nextLines[lineIndex] = formatQuoteLine(value, raw)
}
nextLines.splice(lineIndex + 1, 0, '> ')
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, nextLines)
}
/**
* 인용 블록 원본에서 옵션 선언 줄을 반환한다.
* @param {Object} block - 인용 블록
* @returns {string[]} 옵션 선언 줄
*/
const getQuoteOptionLines = (block) => {
const contentStartLine = getQuoteContentStartLine(block)
if (contentStartLine <= block.meta.startLine) {
return []
}
return getBlockSourceLines(block).slice(0, contentStartLine - block.meta.startLine)
}
/**
* 인용 본문 문자열을 마크다운 줄로 변환한다.
* @param {Object} block - 인용 블록
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {string[]} 인용 마크다운 줄
*/
const buildQuoteBlockLines = (block, payload) => {
const value = typeof payload === 'string'
? payload
: String(payload?.value ?? '')
const bodyLines = String(value ?? '').replace(/\r/g, '').split('\n')
const normalizedBodyLines = bodyLines.length ? bodyLines : ['']
return [
...getQuoteOptionLines(block),
...normalizedBodyLines.map((line) => formatQuoteLine(line, false))
]
}
/**
* 인용 블록 편집 반영
* @param {Object} block - 인용 블록
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {void}
*/
const onQuoteBlockCommit = (block, payload) => {
const normalizedPayload = typeof payload === 'object' && payload !== null
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
: normalizeQuoteEditableValue(payload)
commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
}
/**
* 인용 블록 마지막 줄에서 아래로 이탈한다.
* @param {Object} block - 인용 블록
* @param {string|Object} payload - insert-below 페이로드
* @returns {void}
*/
const onQuoteBlockInsertBelow = (block, payload) => {
const { value } = normalizeInsertBelowPayload(payload)
onQuoteBlockCommit(block, value)
pendingFocusPosition.value = 'start'
pendingFocusOffset.value = 0
onInsertBelowBlock(block, { lines: [''] })
}
/**
* 인용 블록을 일반 문단 줄로 되돌린다.
* @param {Object} block - 인용 블록
* @param {string|Object} payload - 편집 페이로드
* @returns {void}
*/
const onQuoteBlockConvertToParagraph = (block, payload) => {
const { value } = normalizeCommitPayload(payload)
const lines = normalizeQuoteEditableValue(value).split('\n')
const replacementLines = lines.length ? lines : ['']
pendingFocusLine.value = block.meta.startLine
pendingFocusPosition.value = 'start'
commitInlineBlockLines(block, replacementLines)
}
/**
* 라이브 모드에서 현재 줄을 삭제한다.
* @param {number} lineIndex - 줄 번호

View File

@@ -0,0 +1,158 @@
import { unref } from 'vue'
import {
formatQuoteLine,
getQuoteContentStartLine,
normalizeQuoteEditableValue
} from '../lib/markdown-quote.js'
/**
* 관리자 라이브 Markdown 인용 편집 이벤트 상태를 만든다.
* @param {Object} options - 인용 편집 옵션
* @param {import('vue').Ref<string>|string} options.content - Markdown 본문
* @param {import('vue').Ref<number|null>} options.pendingFocusLine - 다음 포커스 줄
* @param {import('vue').Ref<'start'|'end'|'auto'>} options.pendingFocusPosition - 다음 포커스 커서 위치
* @param {import('vue').Ref<number|null>} options.pendingFocusOffset - 다음 포커스 오프셋
* @param {(block: Object, replacementLines: string[]) => void} options.commitInlineBlockLines - 블록 줄 반영 콜백
* @param {(block: Object, options?: { lines?: string[] }) => void} options.insertBelowBlock - 블록 아래 삽입 콜백
* @returns {Object} 인용 이벤트 핸들러
*/
export const useContentMarkdownQuotes = (options) => {
/**
* commit 이벤트 페이로드를 정규화한다.
* @param {string|{ value: string, raw?: boolean }} payload - 페이로드
* @returns {{ value: string, raw: boolean }}
*/
const normalizeCommitPayload = (payload) => {
if (typeof payload === 'string') {
return { value: payload, raw: false }
}
return {
value: String(payload?.value ?? ''),
raw: payload?.raw === true
}
}
/**
* insert-below 이벤트 페이로드를 정규화한다.
* @param {string|Object} payload - 페이로드
* @returns {{ value: string, raw: boolean, before: string, after: string, caretAtStart: boolean, caretAtEnd: boolean }}
*/
const normalizeInsertBelowPayload = (payload) => {
if (typeof payload === 'string') {
return {
value: payload,
raw: false,
before: '',
after: '',
caretAtStart: false,
caretAtEnd: true
}
}
return {
value: String(payload?.value ?? ''),
raw: payload?.raw === true,
before: String(payload?.before ?? ''),
after: String(payload?.after ?? ''),
caretAtStart: payload?.caretAtStart === true,
caretAtEnd: payload?.caretAtEnd === true
}
}
/**
* 블록에 해당하는 원본 마크다운 줄 목록을 반환한다.
* @param {Object} block - 블록
* @returns {string[]} 줄 목록
*/
const getBlockSourceLines = (block) => String(unref(options.content) || '').split('\n').slice(
block.meta.startLine,
(block.meta.endLine ?? block.meta.startLine) + 1
)
/**
* 인용 블록 원본에서 옵션 선언 줄을 반환한다.
* @param {Object} block - 인용 블록
* @returns {string[]} 옵션 선언 줄
*/
const getQuoteOptionLines = (block) => {
const contentStartLine = getQuoteContentStartLine(block)
if (contentStartLine <= block.meta.startLine) {
return []
}
return getBlockSourceLines(block).slice(0, contentStartLine - block.meta.startLine)
}
/**
* 인용 본문 문자열을 마크다운 줄로 변환한다.
* @param {Object} block - 인용 블록
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {string[]} 인용 마크다운 줄
*/
const buildQuoteBlockLines = (block, payload) => {
const value = typeof payload === 'string'
? payload
: String(payload?.value ?? '')
const bodyLines = String(value ?? '').replace(/\r/g, '').split('\n')
const normalizedBodyLines = bodyLines.length ? bodyLines : ['']
return [
...getQuoteOptionLines(block),
...normalizedBodyLines.map((line) => formatQuoteLine(line, false))
]
}
/**
* 인용 블록 편집 반영
* @param {Object} block - 인용 블록
* @param {string|{ value?: string }} payload - 편집 페이로드
* @returns {void}
*/
const onQuoteBlockCommit = (block, payload) => {
const normalizedPayload = typeof payload === 'object' && payload !== null
? { ...payload, value: normalizeQuoteEditableValue(payload.value) }
: normalizeQuoteEditableValue(payload)
options.commitInlineBlockLines(block, buildQuoteBlockLines(block, normalizedPayload))
}
/**
* 인용 블록 마지막 줄에서 아래로 이탈한다.
* @param {Object} block - 인용 블록
* @param {string|Object} payload - insert-below 페이로드
* @returns {void}
*/
const onQuoteBlockInsertBelow = (block, payload) => {
const { value } = normalizeInsertBelowPayload(payload)
onQuoteBlockCommit(block, value)
options.pendingFocusPosition.value = 'start'
options.pendingFocusOffset.value = 0
options.insertBelowBlock(block, { lines: [''] })
}
/**
* 인용 블록을 일반 문단 줄로 되돌린다.
* @param {Object} block - 인용 블록
* @param {string|Object} payload - 편집 페이로드
* @returns {void}
*/
const onQuoteBlockConvertToParagraph = (block, payload) => {
const { value } = normalizeCommitPayload(payload)
const lines = normalizeQuoteEditableValue(value).split('\n')
const replacementLines = lines.length ? lines : ['']
options.pendingFocusLine.value = block.meta.startLine
options.pendingFocusPosition.value = 'start'
options.commitInlineBlockLines(block, replacementLines)
}
return {
buildQuoteBlockLines,
onQuoteBlockCommit,
onQuoteBlockInsertBelow,
onQuoteBlockConvertToParagraph
}
}

View File

@@ -1,3 +1,7 @@
## 2026-07-27 v1.11.102 — 공개 Markdown 인용 편집 이벤트를 분리한다
인용 블록 편집은 옵션 선언 줄 보존, 브라우저 자동 보정으로 생기는 `>. ` 접두 정리, 마지막 줄 아래 문단 이탈, Backspace 기반 일반 문단 복귀를 함께 처리한다. 이 흐름을 렌더러 내부에 남기면 공개 본문 표시와 관리자 라이브 편집 규칙이 계속 섞이므로 `useContentMarkdownQuotes`로 분리한다. 이전 줄 단위 인용 편집 함수는 현재 템플릿에서 사용하지 않으므로 제거해 실제 동작 경로만 테스트 대상으로 유지한다.
## 2026-07-27 v1.11.101 — 공개 Markdown 목록 편집 이벤트를 분리한다
목록 편집은 항목 내용 저장뿐 아니라 원본 번호 유지, raw 마커 보존, Enter로 항목 분리·목록 탈출·아래 문단 삽입을 함께 다룬다. 이를 `ContentMarkdownRenderer` 안에 계속 두면 템플릿과 목록 문법 직렬화가 섞이므로 `useContentMarkdownLists`로 분리한다. 빈 목록 마커는 `- `처럼 본문이 없는 줄도 정확히 목록으로 판별해야 하므로 공통 목록 마커 유틸을 보정해 라이브 편집의 목록 탈출 동작을 테스트 가능한 단위로 유지한다.

View File

@@ -33,6 +33,7 @@
| composables/useContentMarkdownFencedBlocks.js | 관리자 라이브 Markdown 코드·콜아웃·토글 fenced 블록 편집 이벤트와 아래 문단 삽입 상태 관리 |
| composables/useContentMarkdownParagraphs.js | 관리자 라이브 Markdown 문단·스페이서 편집 이벤트와 문단 Enter 단축 변환 관리 |
| composables/useContentMarkdownLists.js | 관리자 라이브 Markdown 목록 번호 계산과 항목 커밋·분리·탈출 이벤트 관리 |
| composables/useContentMarkdownQuotes.js | 관리자 라이브 Markdown 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트 관리 |
| composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 |
| composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 |
@@ -143,6 +144,7 @@
| tests/useContentMarkdownFencedBlocks.test.js | 공개 Markdown fenced 블록 composable의 코드·콜아웃·토글 아래 문단 삽입 단위 테스트 |
| tests/useContentMarkdownParagraphs.test.js | 공개 Markdown 문단 composable의 커밋·분리·인용/코드/콜아웃 단축 변환 단위 테스트 |
| tests/useContentMarkdownLists.test.js | 공개 Markdown 목록 composable의 번호 유지·항목 커밋·Enter 분기 단위 테스트 |
| tests/useContentMarkdownQuotes.test.js | 공개 Markdown 인용 composable의 옵션 줄 보존·접두 정리·아래 이탈·문단 복귀 단위 테스트 |
| tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 |
| tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |
| tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 |

View File

@@ -127,7 +127,7 @@
- 관리자 미디어 업로드 API는 JPG·PNG·WebP 파일을 `/uploads/posts/YYYY/MM/원본파일`에 저장한다. 업로드만으로는 카드 썸네일을 만들지 않고, 해당 이미지가 게시물 대표 이미지로 저장될 때 목록 카드용 WebP 썸네일을 `/uploads/posts/YYYY/MM/thumbs/원본파일명-card.webp`에 생성한다.
- 카드 썸네일은 640×360 기준 `cover` 리사이즈와 WebP 품질 82를 사용한다. GIF·동영상·문서 파일은 썸네일을 자동 생성하지 않는다.
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다. 코드·콜아웃·토글 fenced 블록의 라이브 편집 이벤트는 `useContentMarkdownFencedBlocks`에서 관리한다. 문단·스페이서 편집과 `>`·코드·콜아웃 문단 단축 변환은 `useContentMarkdownParagraphs`에서 관리한다. 목록 번호 계산과 항목 커밋·분리·탈출 이벤트는 `useContentMarkdownLists`에서 관리한다.
- 본문 표시용 이미지는 1600px `thumbs/*-body-1600.webp`와 720px `thumbs/*-body-720.webp`를 함께 생성하고, 공개 본문 단일 이미지·갤러리는 `lib/content-image-urls.js`의 `srcset` 계산으로 화면 폭에 맞는 파일을 우선 사용한다. 공개 본문 라이트박스 대상 목록은 `useContentMarkdownBlocks`에서 `lib/content-gallery-images.js` 기준으로 계산하고, 갤러리 행 분할은 `lib/content-gallery-images.js`에서 계산한다. 관리자 라이브 모드의 단일 이미지·갤러리 드래그 상태와 병합·삽입·분리·정렬 이벤트 중개는 `useContentMarkdownImageDrag`에서 관리한다. 라이브 모드의 선택 확장·전체 선택·선택 삭제·포커스 복원·line focus/blur 중개는 `useContentMarkdownLiveSelection`에서 관리한다. 코드·콜아웃·토글 fenced 블록의 라이브 편집 이벤트는 `useContentMarkdownFencedBlocks`에서 관리한다. 문단·스페이서 편집과 `>`·코드·콜아웃 문단 단축 변환은 `useContentMarkdownParagraphs`에서 관리한다. 목록 번호 계산과 항목 커밋·분리·탈출 이벤트는 `useContentMarkdownLists`에서 관리한다. 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트는 `useContentMarkdownQuotes`에서 관리한다.
- 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다.
- 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다.
- 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다.

View File

@@ -4,7 +4,6 @@
- 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `components/content/ContentMarkdownRenderer.vue`: 남은 인용 편집 이벤트(`onQuote*`)를 도메인별 helper/composable로 추가 분리.
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.11.102
- 공개 Markdown 렌더러의 라이브 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트를 `useContentMarkdownQuotes` composable로 분리.
- 렌더러 안에 남아 있던 미사용 인용 줄 단위 편집 함수와 원본 블록 줄 helper를 제거.
- 인용 composable의 옵션 선언 줄 보존, `>. ` 접두 정리, 아래 문단 삽입, 일반 문단 복귀 단위 테스트 추가.
- v1.11 내부 개선 todo에서 완료된 공개 Markdown 렌더러 편집 이벤트 분리 항목 제거.
## v1.11.101
- 공개 Markdown 렌더러의 라이브 목록 번호 계산·항목 커밋·Enter 분기 이벤트를 `useContentMarkdownLists` composable로 분리.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.11.101",
"version": "1.11.102",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.11.101",
"version": "1.11.102",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.11.101",
"version": "1.11.102",
"private": true,
"type": "module",
"imports": {

View File

@@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { ref } from 'vue'
import { useContentMarkdownQuotes } from '../composables/useContentMarkdownQuotes.js'
/**
* 테스트용 인용 편집 상태를 만든다.
* @param {string} contentText - Markdown 본문
* @returns {{ calls: Object, pendingFocusLine: import('vue').Ref<number|null>, pendingFocusPosition: import('vue').Ref<string>, pendingFocusOffset: import('vue').Ref<number|null>, state: Object }}
*/
const createQuoteState = (contentText = '') => {
const calls = {
commits: [],
inserts: []
}
const pendingFocusLine = ref(null)
const pendingFocusPosition = ref('auto')
const pendingFocusOffset = ref(null)
const state = useContentMarkdownQuotes({
content: ref(contentText),
pendingFocusLine,
pendingFocusPosition,
pendingFocusOffset,
commitInlineBlockLines: (block, replacementLines) => {
calls.commits.push({ block, replacementLines })
},
insertBelowBlock: (block, options) => {
calls.inserts.push({ block, options })
}
})
return {
calls,
pendingFocusLine,
pendingFocusPosition,
pendingFocusOffset,
state
}
}
test('인용 블록 줄 생성은 옵션 선언 줄을 보존한다', () => {
const block = {
type: 'quote',
meta: {
startLine: 1,
endLine: 3,
quoteContentStartLine: 2
}
}
const { state } = createQuoteState([
'제목',
'> [!bg=blue]',
'> 이전',
'> 둘째'
].join('\n'))
assert.deepEqual(state.buildQuoteBlockLines(block, '새 본문\n다음'), [
'> [!bg=blue]',
'> 새 본문',
'> 다음'
])
})
test('인용 블록 커밋은 브라우저 보정으로 생긴 >. 접두를 정리한다', () => {
const block = {
type: 'quote',
meta: {
startLine: 0,
endLine: 0
}
}
const { calls, state } = createQuoteState('> 이전')
state.onQuoteBlockCommit(block, '>. 발타자르')
assert.deepEqual(calls.commits[0], {
block,
replacementLines: ['> 발타자르']
})
})
test('인용 블록 아래 이탈은 현재 내용을 저장하고 아래 문단을 삽입한다', () => {
const block = {
type: 'quote',
meta: {
startLine: 3,
endLine: 4
}
}
const { calls, pendingFocusPosition, pendingFocusOffset, state } = createQuoteState([
'a',
'b',
'c',
'> 이전',
'> 둘째'
].join('\n'))
state.onQuoteBlockInsertBelow(block, {
value: '저장',
before: '저장',
after: '',
caretAtEnd: true
})
assert.equal(pendingFocusPosition.value, 'start')
assert.equal(pendingFocusOffset.value, 0)
assert.deepEqual(calls.commits[0].replacementLines, ['> 저장'])
assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } })
})
test('인용 블록 문단 복귀는 인용 마커 없이 원문 줄을 저장한다', () => {
const block = {
type: 'quote',
meta: {
startLine: 5,
endLine: 6
}
}
const { calls, pendingFocusLine, pendingFocusPosition, state } = createQuoteState('> 이전')
state.onQuoteBlockConvertToParagraph(block, {
value: '첫 줄\n둘째 줄'
})
assert.equal(pendingFocusLine.value, 5)
assert.equal(pendingFocusPosition.value, 'start')
assert.deepEqual(calls.commits[0].replacementLines, ['첫 줄', '둘째 줄'])
})