diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 9673214..88a989a 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -19,8 +19,11 @@ import { import { filterSlashCommands, getSlashCommandFocusTarget, + insertMarkdownLinesAtSlashTarget, + isMediaSlashAction, parseSlashInput, - resolveSlashCommand + resolveSlashCommand, + shouldIgnorePendingMediaSlashCommit } from '../../lib/markdown-slash-commands.js' import { buildCalloutOpenerLine, CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS } from '../../lib/markdown-callout.js' import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js' @@ -2340,9 +2343,11 @@ const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) = return } - if (liveSlashInsertLine.value === startLine - && replacementLines.length === 1 - && parseSlashInput(replacementLines[0])) { + if (shouldIgnorePendingMediaSlashCommit({ + pendingInsertLine: liveSlashInsertLine.value, + startLine, + replacementLines + })) { return } @@ -2794,7 +2799,7 @@ const applyLiveSlashCommand = (command) => { const line = liveSlashSourceLine.value - if (['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'].includes(command.action)) { + if (isMediaSlashAction(command.action)) { liveSlashInsertLine.value = line replaceLineRange(line, line, [], false) onLiveSlashEnd() @@ -2843,14 +2848,7 @@ const onLiveSlashApply = (payload) => { * @returns {void} */ const insertMarkdownAtLine = (lineIndex, insertLines) => { - const sourceLines = (markdownValue.value ?? '').split('\n') - const replaceCount = parseSlashInput(sourceLines[lineIndex] ?? '') ? 1 : 0 - - markdownValue.value = [ - ...sourceLines.slice(0, lineIndex), - ...insertLines, - ...sourceLines.slice(lineIndex + replaceCount) - ].join('\n') + markdownValue.value = insertMarkdownLinesAtSlashTarget(markdownValue.value, lineIndex, insertLines) } const onPreviewMergeWithPreviousLine = ({ lineIndex, mergedLine }) => { diff --git a/docs/map.md b/docs/map.md index f44681a..554d28b 100644 --- a/docs/map.md +++ b/docs/map.md @@ -58,7 +58,7 @@ | lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 | | lib/markdown-table.js | 공개 Markdown 표 행·구분선·정렬·블록 파싱 | | lib/markdown-toc.js | 공개 게시글 TOC용 H1~H3 제목 추출과 앵커 ID 생성 | -| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의 | +| lib/markdown-slash-commands.js | 관리자 Markdown-first 에디터 슬래시 명령 목록과 삽입 줄 정의, 미디어 명령 action 판별, 미디어 명령 지연 commit 무시·슬래시 원문 줄 교체 삽입 helper | | lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 | | lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | @@ -117,6 +117,7 @@ | tests/content-gallery-images.test.js | 공개 본문 라이트박스 이미지 목록과 갤러리 행 분할 단위 회귀 테스트 | | tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 | | tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 | +| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·포커스 대상·원문 줄 교체 삽입 단위 회귀 테스트 | | 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/update.md b/docs/update.md index d972a77..29eea89 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.67 + +- 관리자 Markdown 에디터의 슬래시 명령 미디어 액션 판별·지연 commit 무시·명령 줄 교체 삽입 로직을 공통 helper로 분리. +- 슬래시 명령 파싱·미디어 액션·포커스 대상·미디어 삽입 줄 교체 단위 테스트 추가. + ## v1.11.66 - 라이브 작성 모드에서 오른쪽 코드 블록 설정의 줄번호 토글을 누르면 패널이 닫히는 문제 수정. diff --git a/lib/markdown-slash-commands.js b/lib/markdown-slash-commands.js index 9f9b831..e4ea9b9 100644 --- a/lib/markdown-slash-commands.js +++ b/lib/markdown-slash-commands.js @@ -219,6 +219,47 @@ export const resolveSlashCommand = (query) => { return exact ?? matches[0] } +/** + * 미디어 선택 모달을 여는 슬래시 명령인지 확인한다. + * @param {string} action - 명령 action + * @returns {boolean} 미디어 명령 여부 + */ +export const isMediaSlashAction = (action) => ['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'] + .includes(String(action || '')) + +/** + * 라이브 모드 미디어 슬래시 명령 처리 중 지연 commit을 무시할지 확인한다. + * @param {Object} options - 판별 옵션 + * @param {number|null} options.pendingInsertLine - 미디어 삽입 대기 줄 + * @param {number} options.startLine - commit 시작 줄 + * @param {string[]} options.replacementLines - commit 대체 줄 + * @returns {boolean} 무시 여부 + */ +export const shouldIgnorePendingMediaSlashCommit = ({ pendingInsertLine, startLine, replacementLines }) => pendingInsertLine === startLine + && Array.isArray(replacementLines) + && replacementLines.length === 1 + && Boolean(parseSlashInput(replacementLines[0])) + +/** + * 지정 줄에 마크다운 줄을 삽입하되, 해당 줄이 슬래시 명령이면 교체한다. + * @param {string} markdown - 원본 마크다운 + * @param {number} lineIndex - 삽입 위치 + * @param {string[]} insertLines - 삽입 줄 + * @returns {string} 갱신된 마크다운 + */ +export const insertMarkdownLinesAtSlashTarget = (markdown, lineIndex, insertLines) => { + const sourceLines = String(markdown ?? '').split('\n') + const safeLine = Math.max(0, Math.min(Number.isFinite(lineIndex) ? lineIndex : 0, sourceLines.length)) + const lines = Array.isArray(insertLines) ? insertLines : [] + const replaceCount = parseSlashInput(sourceLines[safeLine] ?? '') ? 1 : 0 + + return [ + ...sourceLines.slice(0, safeLine), + ...lines, + ...sourceLines.slice(safeLine + replaceCount) + ].join('\n') +} + /** * 슬래시 명령 적용 후 포커스할 줄·커서 오프셋을 계산한다. * @param {number} startLine - 교체 시작 줄(0-based) diff --git a/package-lock.json b/package-lock.json index cc60ed4..e9f38c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.66", + "version": "1.11.67", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.66", + "version": "1.11.67", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index ec31cfc..0734cf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.66", + "version": "1.11.67", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-slash-commands.test.js b/tests/markdown-slash-commands.test.js new file mode 100644 index 0000000..d6a491b --- /dev/null +++ b/tests/markdown-slash-commands.test.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + getSlashCommandFocusTarget, + insertMarkdownLinesAtSlashTarget, + isMediaSlashAction, + parseSlashInput, + resolveSlashCommand, + shouldIgnorePendingMediaSlashCommit +} from '../lib/markdown-slash-commands.js' + +test('슬래시 입력은 단일 줄 명령만 파싱한다', () => { + assert.deepEqual(parseSlashInput('/image'), { + query: 'image', + raw: '/image' + }) + assert.equal(parseSlashInput(' /image'), null) + assert.equal(parseSlashInput('/image\n다음 줄'), null) +}) + +test('정확한 슬래시 명령과 미디어 액션을 판별한다', () => { + assert.equal(resolveSlashCommand('image')?.action, 'media-image') + assert.equal(resolveSlashCommand('img')?.id, 'image') + assert.equal(isMediaSlashAction('media-image'), true) + assert.equal(isMediaSlashAction('lines'), false) +}) + +test('미디어 슬래시 명령 지연 commit은 같은 줄 slash 원문일 때만 무시한다', () => { + assert.equal(shouldIgnorePendingMediaSlashCommit({ + pendingInsertLine: 3, + startLine: 3, + replacementLines: ['/image'] + }), true) + assert.equal(shouldIgnorePendingMediaSlashCommit({ + pendingInsertLine: 3, + startLine: 4, + replacementLines: ['/image'] + }), false) + assert.equal(shouldIgnorePendingMediaSlashCommit({ + pendingInsertLine: 3, + startLine: 3, + replacementLines: ['본문'] + }), false) +}) + +test('슬래시 명령 줄에 미디어를 삽입할 때 원문 줄을 교체한다', () => { + const markdown = [ + '첫 줄', + '/image', + '마지막 줄' + ].join('\n') + + assert.equal(insertMarkdownLinesAtSlashTarget(markdown, 1, ['![](/uploads/a.png)']), [ + '첫 줄', + '![](/uploads/a.png)', + '마지막 줄' + ].join('\n')) +}) + +test('일반 줄에는 지정 위치 앞에 마크다운 줄을 삽입한다', () => { + assert.equal(insertMarkdownLinesAtSlashTarget('첫 줄\n마지막 줄', 1, ['중간 줄']), '첫 줄\n중간 줄\n마지막 줄') +}) + +test('슬래시 명령 포커스 대상은 단일 줄과 블록 삽입을 구분한다', () => { + assert.deepEqual(getSlashCommandFocusTarget(2, ['## ']), { + line: 2, + offset: 3 + }) + assert.deepEqual(getSlashCommandFocusTarget(2, ['```', '', '```']), { + line: 3, + offset: 0 + }) +})