From 9efa6d6af5edf6a4bcaa08cc5ddb00c3621e5b6b Mon Sep 17 00:00:00 2001 From: zenn Date: Mon, 27 Jul 2026 16:29:23 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EB=B3=B8=EB=AC=B8=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8E=B8=EC=A7=91=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownRenderer.vue | 154 ++---------- composables/useContentMarkdownLists.js | 222 ++++++++++++++++++ docs/history.md | 4 + docs/map.md | 2 + docs/spec.md | 2 +- docs/todo.md | 2 +- docs/update.md | 7 + lib/markdown-live-edit.js | 12 +- package-lock.json | 4 +- package.json | 2 +- tests/useContentMarkdownLists.test.js | 147 ++++++++++++ 11 files changed, 407 insertions(+), 151 deletions(-) create mode 100644 composables/useContentMarkdownLists.js create mode 100644 tests/useContentMarkdownLists.test.js diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 9f38ec4..80dfcc9 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -12,11 +12,7 @@ import { import { appendTextToMarkdownLine, getAppendTextForMerge, - getMergeJunctionDisplayOffset, - hasListMarker, - isEmptyListMarkerLine, - parseOrderedListMarker, - stripListMarker + getMergeJunctionDisplayOffset } from '../../lib/markdown-live-edit.js' import { CALLOUT_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js' import { @@ -32,6 +28,7 @@ import { useContentMarkdownImageDrag } from '../../composables/useContentMarkdow import { useContentMarkdownLiveSelection } from '../../composables/useContentMarkdownLiveSelection.js' import { useContentMarkdownFencedBlocks } from '../../composables/useContentMarkdownFencedBlocks.js' import { useContentMarkdownParagraphs } from '../../composables/useContentMarkdownParagraphs.js' +import { useContentMarkdownLists } from '../../composables/useContentMarkdownLists.js' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' import ProseCodeBlock from './ProseCodeBlock.vue' import ProseHtml from './ProseHtml.vue' @@ -190,6 +187,18 @@ const { commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines) }) +const { + getListMarkerNumber, + onListItemInlineCommit, + onListItemInsertBelow +} = useContentMarkdownLists({ + content: computed(() => props.content), + pendingFocusLine, + pendingFocusPosition, + commitInlineBlockLines: (block, replacementLines) => commitInlineBlockLines(block, replacementLines), + insertBelowBlock: (block, options) => onInsertBelowBlock(block, options) +}) + /** * 빈 줄 공백 블록 높이를 반환한다. * @param {Object} block - 렌더링 블록 @@ -401,64 +410,6 @@ const getBlockSourceLines = (block) => String(props.content || '').split('\n').s (block.meta.endLine ?? block.meta.startLine) + 1 ) -/** - * 목록 항목 마크다운 줄을 만든다. - * @param {string} value - 편집 값 - * @param {boolean} raw - 원문 모드 여부 - * @param {Object} block - 목록 블록 - * @param {number} itemIndex - 항목 인덱스 - * @returns {string} 마크다운 줄 - */ -/** - * 순서 목록 항목에 표시할 번호를 반환한다. - * @param {Object} block - 목록 블록 - * @param {number} itemIndex - 항목 인덱스 - * @returns {number} 목록 번호 - */ -const getListMarkerNumber = (block, itemIndex) => { - if (block.listNumbers?.[itemIndex] != null) { - return block.listNumbers[itemIndex] - } - - const line = getMarkdownLine(block.meta.startLine + itemIndex) - const parsed = parseOrderedListMarker(line) - - return parsed?.number ?? itemIndex + 1 -} - -const formatListLine = (value, raw, block, itemIndex) => { - if (raw) { - if (!hasListMarker(value, block.ordered)) { - return String(value ?? '').trim() - } - - return value - } - - const clean = stripListMarker(value, block.ordered) - - if (block.ordered) { - const number = getListMarkerNumber(block, itemIndex) - return clean ? `${number}. ${clean}` : `${number}. ` - } - - return clean ? `- ${clean}` : '- ' -} - -/** - * 빈 목록 마커 줄을 만든다. - * @param {Object} block - 목록 블록 - * @param {number} itemIndex - 항목 인덱스 - * @returns {string} 마크다운 줄 - */ -const createEmptyListMarkerLine = (block, itemIndex) => { - if (block.ordered) { - return `${getListMarkerNumber(block, itemIndex)}. ` - } - - return '- ' -} - /** * 인라인 편집 결과를 마크다운 줄로 반영한다. * @param {Object} block - 블록 @@ -676,58 +627,6 @@ const onPreviewCardKeydown = (event, block) => { } } -/** - * 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다. - * @param {Object} block - 목록 블록 - * @param {number} itemIndex - 항목 인덱스 - * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 - * @returns {void} - */ -const onListItemInsertBelow = (block, itemIndex, payload) => { - const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload) - const nextLines = getBlockSourceLines(block) - - if (caretAtStart && after.length) { - nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex) - nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex)) - pendingFocusLine.value = block.meta.startLine + itemIndex - pendingFocusPosition.value = 'start' - commitInlineBlockLines(block, nextLines) - return - } - - if (before.length && after.length) { - nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex) - nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1)) - pendingFocusLine.value = block.meta.startLine + itemIndex + 1 - pendingFocusPosition.value = 'start' - commitInlineBlockLines(block, nextLines) - return - } - - if (itemIndex < nextLines.length) { - nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex) - } - - const committedLine = nextLines[itemIndex] ?? '' - - if (isEmptyListMarkerLine(committedLine, block.ordered)) { - nextLines[itemIndex] = '' - pendingFocusLine.value = block.meta.startLine + itemIndex - pendingFocusPosition.value = 'start' - commitInlineBlockLines(block, nextLines) - return - } - - commitInlineBlockLines(block, nextLines) - - if (caretAtEnd || !before.length) { - pendingFocusLine.value = (block.meta.endLine ?? block.meta.startLine) + 1 - pendingFocusPosition.value = 'start' - onInsertBelowBlock(block, { lines: [''] }) - } -} - /** * 라이브 모드 하단 클릭 — 새 문단 추가 * @returns {void} @@ -917,31 +816,6 @@ const onQuoteBlockConvertToParagraph = (block, payload) => { commitInlineBlockLines(block, replacementLines) } -/** - * 목록 항목 인라인 편집 반영 - * @param {Object} block - 블록 - * @param {number} itemIndex - 항목 인덱스 - * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 - * @returns {void} - */ -const onListItemInlineCommit = (block, itemIndex, 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.map((line, index) => { - if (index !== itemIndex) { - return line - } - - return formatListLine(value, raw, block, index) - }) - - commitInlineBlockLines(block, nextLines) -} - /** * 라이브 모드에서 현재 줄을 삭제한다. * @param {number} lineIndex - 줄 번호 diff --git a/composables/useContentMarkdownLists.js b/composables/useContentMarkdownLists.js new file mode 100644 index 0000000..77a4082 --- /dev/null +++ b/composables/useContentMarkdownLists.js @@ -0,0 +1,222 @@ +import { unref } from 'vue' +import { + hasListMarker, + isEmptyListMarkerLine, + parseOrderedListMarker, + stripListMarker +} from '../lib/markdown-live-edit.js' + +/** + * 관리자 라이브 Markdown 목록 편집 이벤트 상태를 만든다. + * @param {Object} options - 목록 편집 옵션 + * @param {import('vue').Ref|string} options.content - Markdown 본문 + * @param {import('vue').Ref} options.pendingFocusLine - 다음 포커스 줄 + * @param {import('vue').Ref<'start'|'end'|'auto'>} options.pendingFocusPosition - 다음 포커스 커서 위치 + * @param {(block: Object, replacementLines: string[]) => void} options.commitInlineBlockLines - 블록 줄 반영 콜백 + * @param {(block: Object, options?: { lines?: string[] }) => void} options.insertBelowBlock - 블록 아래 삽입 콜백 + * @returns {Object} 목록 이벤트 핸들러 + */ +export const useContentMarkdownLists = (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 {number} lineIndex - 줄 번호 + * @returns {string} 줄 텍스트 + */ + const getMarkdownLine = (lineIndex) => String(unref(options.content) || '').split('\n')[lineIndex] ?? '' + + /** + * 블록에 해당하는 원본 마크다운 줄 목록을 반환한다. + * @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 {number} line - 줄 번호 + * @returns {void} + */ + const prepareListFocus = (line) => { + options.pendingFocusLine.value = line + options.pendingFocusPosition.value = 'start' + } + + /** + * 순서 목록 항목에 표시할 번호를 반환한다. + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @returns {number} 목록 번호 + */ + const getListMarkerNumber = (block, itemIndex) => { + if (block.listNumbers?.[itemIndex] != null) { + return block.listNumbers[itemIndex] + } + + const line = getMarkdownLine(block.meta.startLine + itemIndex) + const parsed = parseOrderedListMarker(line) + + return parsed?.number ?? itemIndex + 1 + } + + /** + * 목록 항목 마크다운 줄을 만든다. + * @param {string} value - 편집 값 + * @param {boolean} raw - 원문 모드 여부 + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @returns {string} 마크다운 줄 + */ + const formatListLine = (value, raw, block, itemIndex) => { + if (raw) { + if (!hasListMarker(value, block.ordered)) { + return String(value ?? '').trim() + } + + return value + } + + const clean = stripListMarker(value, block.ordered) + + if (block.ordered) { + const number = getListMarkerNumber(block, itemIndex) + return clean ? `${number}. ${clean}` : `${number}. ` + } + + return clean ? `- ${clean}` : '- ' + } + + /** + * 빈 목록 마커 줄을 만든다. + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @returns {string} 마크다운 줄 + */ + const createEmptyListMarkerLine = (block, itemIndex) => { + if (block.ordered) { + return `${getListMarkerNumber(block, itemIndex)}. ` + } + + return '- ' + } + + /** + * 목록 항목 인라인 편집 반영 + * @param {Object} block - 블록 + * @param {number} itemIndex - 항목 인덱스 + * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 + * @returns {void} + */ + const onListItemInlineCommit = (block, itemIndex, payload) => { + const { value, raw } = normalizeCommitPayload(payload) + const sourceLines = getBlockSourceLines(block) + const nextLines = sourceLines.map((line, index) => { + if (index !== itemIndex) { + return line + } + + return formatListLine(value, raw, block, index) + }) + + options.commitInlineBlockLines(block, nextLines) + } + + /** + * 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다. + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 + * @returns {void} + */ + const onListItemInsertBelow = (block, itemIndex, payload) => { + const { value, raw, before, after, caretAtStart, caretAtEnd } = normalizeInsertBelowPayload(payload) + const nextLines = getBlockSourceLines(block) + + if (caretAtStart && after.length) { + nextLines[itemIndex] = formatListLine(after, raw, block, itemIndex) + nextLines.splice(itemIndex, 0, createEmptyListMarkerLine(block, itemIndex)) + prepareListFocus(block.meta.startLine + itemIndex) + options.commitInlineBlockLines(block, nextLines) + return + } + + if (before.length && after.length) { + nextLines[itemIndex] = formatListLine(before, raw, block, itemIndex) + nextLines.splice(itemIndex + 1, 0, formatListLine(after, raw, block, itemIndex + 1)) + prepareListFocus(block.meta.startLine + itemIndex + 1) + options.commitInlineBlockLines(block, nextLines) + return + } + + if (itemIndex < nextLines.length) { + nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex) + } + + const committedLine = nextLines[itemIndex] ?? '' + + if (isEmptyListMarkerLine(committedLine, block.ordered)) { + nextLines[itemIndex] = '' + prepareListFocus(block.meta.startLine + itemIndex) + options.commitInlineBlockLines(block, nextLines) + return + } + + options.commitInlineBlockLines(block, nextLines) + + if (caretAtEnd || !before.length) { + prepareListFocus((block.meta.endLine ?? block.meta.startLine) + 1) + options.insertBelowBlock(block, { lines: [''] }) + } + } + + return { + getListMarkerNumber, + onListItemInlineCommit, + onListItemInsertBelow + } +} diff --git a/docs/history.md b/docs/history.md index ab1acc4..b325ad9 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-27 v1.11.101 — 공개 Markdown 목록 편집 이벤트를 분리한다 + +목록 편집은 항목 내용 저장뿐 아니라 원본 번호 유지, raw 마커 보존, Enter로 항목 분리·목록 탈출·아래 문단 삽입을 함께 다룬다. 이를 `ContentMarkdownRenderer` 안에 계속 두면 템플릿과 목록 문법 직렬화가 섞이므로 `useContentMarkdownLists`로 분리한다. 빈 목록 마커는 `- `처럼 본문이 없는 줄도 정확히 목록으로 판별해야 하므로 공통 목록 마커 유틸을 보정해 라이브 편집의 목록 탈출 동작을 테스트 가능한 단위로 유지한다. + ## 2026-07-27 v1.11.100 — 공개 Markdown 문단 편집 이벤트를 분리한다 문단 편집은 일반 텍스트 커밋뿐 아니라 빈 줄 스페이서 입력, `>` 인용 변환, 코드·콜아웃 단축 블록 생성까지 담당한다. 이 흐름을 렌더러 안에 남기면 템플릿과 라이브 입력 규칙이 계속 섞이므로 `useContentMarkdownParagraphs`로 분리한다. 렌더러는 문단 편집 컴포넌트 이벤트 연결에 집중하고, 문단 단축 문법과 포커스 예약은 composable에서 테스트 가능한 단위로 유지한다. diff --git a/docs/map.md b/docs/map.md index 18c8a38..01f27a9 100644 --- a/docs/map.md +++ b/docs/map.md @@ -32,6 +32,7 @@ | composables/useContentMarkdownLiveSelection.js | 관리자 라이브 Markdown 본문의 Selection Bridge, 포커스 복원, line focus/blur, 원문 편집 표시 상태 관리 | | composables/useContentMarkdownFencedBlocks.js | 관리자 라이브 Markdown 코드·콜아웃·토글 fenced 블록 편집 이벤트와 아래 문단 삽입 상태 관리 | | composables/useContentMarkdownParagraphs.js | 관리자 라이브 Markdown 문단·스페이서 편집 이벤트와 문단 Enter 단축 변환 관리 | +| composables/useContentMarkdownLists.js | 관리자 라이브 Markdown 목록 번호 계산과 항목 커밋·분리·탈출 이벤트 관리 | | composables/useAdminMediaThumbnailRegeneration.js | 관리자 미디어 화면 대표 이미지·페이지 커버·본문 이미지 재생성 가능 여부와 API 호출 상태 | | composables/usePublicSiteSettings.js | 공개 화면의 `/api/site-settings` 조회 key(`site-settings-public`)와 기본 설정값 통일 | @@ -141,6 +142,7 @@ | tests/useContentMarkdownLiveSelection.test.js | 공개 Markdown 라이브 선택 composable의 포커스 요청·예약 포커스 초기화·line focus/blur 단위 테스트 | | tests/useContentMarkdownFencedBlocks.test.js | 공개 Markdown fenced 블록 composable의 코드·콜아웃·토글 아래 문단 삽입 단위 테스트 | | tests/useContentMarkdownParagraphs.test.js | 공개 Markdown 문단 composable의 커밋·분리·인용/코드/콜아웃 단축 변환 단위 테스트 | +| tests/useContentMarkdownLists.test.js | 공개 Markdown 목록 composable의 번호 유지·항목 커밋·Enter 분기 단위 테스트 | | tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 | | tests/markdown-quote.test.js | Markdown 인용 옵션 파싱과 편집 줄 변환 단위 회귀 테스트 | diff --git a/docs/spec.md b/docs/spec.md index c33fbc6..f1bcb4e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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`에서 관리한다. +- 본문 표시용 이미지는 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`에서 관리한다. - 라이트박스용 이미지는 2560px `thumbs/*-body-2560.webp`를 생성해 크게 보기 화면에서 우선 사용한다. 별도 원본 보기 UI는 제공하지 않으며, `lib/content-image-urls.js`가 파생 URL을 선택하고 파생 이미지 로드 실패 시에만 원본으로 fallback한다. - 기존 운영 볼륨에 생성된 `thumbs/*-body.webp`, `thumbs/*-lightbox.webp`는 레거시 본문 파생 이미지로 계속 인식해 관리자 미디어의 본문 이미지 탭과 사용처 연결을 유지한다. 새 저장·재생성·백필은 해상도형 파일명만 만든다. - 관리자 미디어의 원본 이미지 사용처는 원본 URL 직접 참조뿐 아니라 해당 원본에서 생성된 카드·페이지 커버·본문 파생 이미지 URL 사용처까지 함께 반영한다. 사용처 계산은 관리자 글 목록 요약 API와 분리된 미디어 참조 전용 조회로 게시물·페이지의 본문과 대표 이미지만 확인한다. 따라서 공개 본문이 `body-1600` 같은 파생 이미지를 사용하더라도 원본은 미사용 삭제 대상에서 제외되고, 본문 파생 이미지도 실제 본문 사용처가 있으면 미사용으로 분류하지 않는다. 원본이 실제로 미사용이라 삭제될 때는 연결된 카드·페이지 커버·본문 자동 생성 이미지도 함께 삭제한다. diff --git a/docs/todo.md b/docs/todo.md index 3862e97..aac4596 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,7 +4,7 @@ - 거대 컴포넌트 책임 분리 진행. - `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리. - - `components/content/ContentMarkdownRenderer.vue`: 남은 목록·인용 편집 이벤트(`onList*`, `onQuote*`)를 도메인별 helper/composable로 추가 분리. + - `components/content/ContentMarkdownRenderer.vue`: 남은 인용 편집 이벤트(`onQuote*`)를 도메인별 helper/composable로 추가 분리. - 미디어 카탈로그 전환 코드 수명 정리. - 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토. - fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리. diff --git a/docs/update.md b/docs/update.md index fc9af5d..25c4487 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.11.101 + +- 공개 Markdown 렌더러의 라이브 목록 번호 계산·항목 커밋·Enter 분기 이벤트를 `useContentMarkdownLists` composable로 분리. +- 빈 목록 마커 판별 유틸을 보정해 `- `, `1. ` 같은 빈 목록 줄의 문단 탈출 처리를 안정화. +- 목록 composable의 순서 목록 번호 유지, 항목 커밋, 중간 분리, 빈 마커 탈출, 아래 문단 삽입, 원문 모드 단위 테스트 추가. +- v1.11 내부 개선 todo에서 완료된 목록 편집 이벤트 분리 범위를 제외하고 남은 블록 이벤트 범위를 인용으로 재정리. + ## v1.11.100 - 공개 Markdown 렌더러의 라이브 문단·스페이서 편집 이벤트와 문단 Enter 단축 변환을 `useContentMarkdownParagraphs` composable로 분리. diff --git a/lib/markdown-live-edit.js b/lib/markdown-live-edit.js index dc4b237..a8a8241 100644 --- a/lib/markdown-live-edit.js +++ b/lib/markdown-live-edit.js @@ -12,13 +12,13 @@ export const stripQuoteMarker = (value) => String(value ?? '').replace(/^(?:>\s* * @returns {string} 본문 */ export const stripListMarker = (value, ordered = false) => { - const raw = String(value ?? '').trim() + const raw = String(value ?? '') if (ordered) { - return raw.replace(/^\d+\.\s*/, '').trim() + return raw.replace(/^\s*\d+\.\s*/, '').trim() } - return raw.replace(/^[-*+]\s+/, '').trim() + return raw.replace(/^\s*[-*+]\s*/, '').trim() } /** @@ -35,13 +35,13 @@ export const hasQuoteMarker = (value) => /^\s*>/.test(String(value ?? '')) * @returns {boolean} */ export const hasListMarker = (value, ordered = false) => { - const raw = String(value ?? '').trim() + const raw = String(value ?? '') if (ordered) { - return /^\d+\.\s*/.test(raw) + return /^\s*\d+\.(?:\s|$)/.test(raw) } - return /^[-*+]\s/.test(raw) + return /^\s*[-*+](?:\s|$)/.test(raw) } /** diff --git a/package-lock.json b/package-lock.json index a93558a..da800c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.100", + "version": "1.11.101", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.100", + "version": "1.11.101", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 31d79f3..2286703 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.100", + "version": "1.11.101", "private": true, "type": "module", "imports": { diff --git a/tests/useContentMarkdownLists.test.js b/tests/useContentMarkdownLists.test.js new file mode 100644 index 0000000..4dab643 --- /dev/null +++ b/tests/useContentMarkdownLists.test.js @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { ref } from 'vue' +import { useContentMarkdownLists } from '../composables/useContentMarkdownLists.js' + +/** + * 테스트용 목록 편집 상태를 만든다. + * @param {string} contentText - Markdown 본문 + * @returns {{ calls: Object, pendingFocusLine: import('vue').Ref, pendingFocusPosition: import('vue').Ref, state: Object }} + */ +const createListState = (contentText = '') => { + const calls = { + commits: [], + inserts: [] + } + const pendingFocusLine = ref(null) + const pendingFocusPosition = ref('auto') + const state = useContentMarkdownLists({ + content: ref(contentText), + pendingFocusLine, + pendingFocusPosition, + commitInlineBlockLines: (block, replacementLines) => { + calls.commits.push({ block, replacementLines }) + }, + insertBelowBlock: (block, options) => { + calls.inserts.push({ block, options }) + } + }) + + return { + calls, + pendingFocusLine, + pendingFocusPosition, + state + } +} + +test('순서 목록 번호는 원본 줄 번호를 우선 사용한다', () => { + const block = { + type: 'list', + ordered: true, + meta: { startLine: 1, endLine: 2 } + } + const { state } = createListState(['제목', '4. 첫째', '5. 둘째'].join('\n')) + + assert.equal(state.getListMarkerNumber(block, 0), 4) + assert.equal(state.getListMarkerNumber(block, 1), 5) +}) + +test('목록 항목 커밋은 선택 항목만 마크다운 목록 줄로 갱신한다', () => { + const block = { + type: 'list', + ordered: false, + meta: { startLine: 0, endLine: 1 } + } + const { calls, state } = createListState(['- 기존', '- 다음'].join('\n')) + + state.onListItemInlineCommit(block, 0, '변경') + + assert.deepEqual(calls.commits[0], { + block, + replacementLines: ['- 변경', '- 다음'] + }) +}) + +test('목록 항목 중간 Enter는 현재 항목을 둘로 나누고 다음 줄에 포커스를 둔다', () => { + const block = { + type: 'list', + ordered: false, + meta: { startLine: 3, endLine: 4 } + } + const { calls, pendingFocusLine, pendingFocusPosition, state } = createListState([ + 'a', + 'b', + 'c', + '- 앞뒤', + '- 다음' + ].join('\n')) + + state.onListItemInsertBelow(block, 0, { + before: '앞', + after: '뒤', + value: '앞뒤' + }) + + assert.equal(pendingFocusLine.value, 4) + assert.equal(pendingFocusPosition.value, 'start') + assert.deepEqual(calls.commits[0].replacementLines, ['- 앞', '- 뒤', '- 다음']) +}) + +test('빈 목록 마커 Enter는 해당 줄을 일반 문단 줄로 탈출시킨다', () => { + const block = { + type: 'list', + ordered: false, + meta: { startLine: 2, endLine: 2 } + } + const { calls, pendingFocusLine, pendingFocusPosition, state } = createListState([ + 'a', + 'b', + '- ' + ].join('\n')) + + state.onListItemInsertBelow(block, 0, '- ') + + assert.equal(pendingFocusLine.value, 2) + assert.equal(pendingFocusPosition.value, 'start') + assert.deepEqual(calls.commits[0].replacementLines, ['']) + assert.equal(calls.inserts.length, 0) +}) + +test('목록 항목 끝 Enter는 현재 줄 저장 후 아래 문단을 삽입한다', () => { + const block = { + type: 'list', + ordered: true, + listNumbers: [7], + meta: { startLine: 0, endLine: 0 } + } + const { calls, pendingFocusLine, pendingFocusPosition, state } = createListState('7. 기존') + + state.onListItemInsertBelow(block, 0, { + value: '완료', + before: '완료', + after: '', + caretAtEnd: true + }) + + assert.equal(pendingFocusLine.value, 1) + assert.equal(pendingFocusPosition.value, 'start') + assert.deepEqual(calls.commits[0].replacementLines, ['7. 완료']) + assert.deepEqual(calls.inserts[0], { block, options: { lines: [''] } }) +}) + +test('원문 모드 목록 커밋은 이미 있는 마커를 그대로 보존한다', () => { + const block = { + type: 'list', + ordered: false, + meta: { startLine: 0, endLine: 0 } + } + const { calls, state } = createListState('- 기존') + + state.onListItemInlineCommit(block, 0, { + value: '* 원문', + raw: true + }) + + assert.deepEqual(calls.commits[0].replacementLines, ['* 원문']) +})