diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 88a989a..5ed5da0 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -16,6 +16,11 @@ import { getMarkdownLineBodyPrefixLength, getSelectionOffsetInElement } from '../../lib/markdown-live-selection.js' +import { + createEditorHistoryEntry, + getEditorHistoryEntrySelection, + getEditorHistoryEntryValue +} from '../../lib/markdown-editor-history.js' import { filterSlashCommands, getSlashCommandFocusTarget, @@ -664,63 +669,6 @@ const isCodeFenceBodyLine = (lines, lineIndex) => { return false } -/** - * 에디터 히스토리 항목을 만든다. - * @param {string} value - 저장할 본문 - * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 히스토리 항목 - */ -const createEditorHistoryEntry = (value) => { - const safeValue = String(value ?? '') - - return { - value: safeValue, - selectionStart: Math.min(lastSelectionState.value.start, safeValue.length), - selectionEnd: Math.min(lastSelectionState.value.end, safeValue.length) - } -} - -/** - * 히스토리 항목의 본문 값을 반환한다. - * @param {string|{ value?: string }} entry - 히스토리 항목 - * @returns {string|null} 본문 값 - */ -const getEditorHistoryEntryValue = (entry) => { - if (typeof entry === 'string') { - return entry - } - - if (entry && typeof entry.value === 'string') { - return entry.value - } - - return null -} - -/** - * 히스토리 항목의 선택 위치를 반환한다. - * @param {string|{ value?: string, selectionStart?: number, selectionEnd?: number }} entry - 히스토리 항목 - * @param {string} value - 복원된 본문 - * @returns {{ start: number, end: number }|null} 선택 위치 - */ -const getEditorHistoryEntrySelection = (entry, value) => { - if (!entry || typeof entry === 'string') { - return null - } - - const length = String(value ?? '').length - const start = Number(entry.selectionStart) - const end = Number(entry.selectionEnd) - - if (!Number.isFinite(start) || !Number.isFinite(end)) { - return null - } - - return { - start: Math.min(Math.max(0, start), length), - end: Math.min(Math.max(0, end), length) - } -} - /** * 현재 선택 위치에 맞춰 에디터 포커스를 복원한다. * @param {{ start: number, end: number }|null} selection - 복원할 선택 영역 @@ -765,7 +713,7 @@ const recordEditorHistory = (previousValue) => { const shouldGroup = now - lastEditorHistoryRecordAt < 900 if (!shouldGroup || !editorUndoStack.value.length) { - editorUndoStack.value = [...editorUndoStack.value, createEditorHistoryEntry(value)].slice(-EDITOR_UNDO_LIMIT) + editorUndoStack.value = [...editorUndoStack.value, createEditorHistoryEntry(value, lastSelectionState.value)].slice(-EDITOR_UNDO_LIMIT) } editorRedoStack.value = [] @@ -788,7 +736,7 @@ const restoreEditorHistory = (direction) => { } sourceStack.value = sourceStack.value.slice(0, -1) - targetStack.value = [...targetStack.value, createEditorHistoryEntry(markdownValue.value ?? '')].slice(-EDITOR_UNDO_LIMIT) + targetStack.value = [...targetStack.value, createEditorHistoryEntry(markdownValue.value ?? '', lastSelectionState.value)].slice(-EDITOR_UNDO_LIMIT) isRestoringEditorHistory.value = true markdownValue.value = nextValue lastEditorHistoryRecordAt = 0 diff --git a/docs/map.md b/docs/map.md index 554d28b..d5b1f02 100644 --- a/docs/map.md +++ b/docs/map.md @@ -47,6 +47,7 @@ | lib/recommended-site-icon-url.js | 추천 사이트 아이콘 URL 우선순위(수동 썸네일 > 서버 캐시 > 외부 프록시) | | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | +| lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 | | lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 | | lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 | | lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출, `allowfullscreen`을 `allow=fullscreen` 권한으로 정규화 | @@ -117,6 +118,7 @@ | tests/content-gallery-images.test.js | 공개 본문 라이트박스 이미지 목록과 갤러리 행 분할 단위 회귀 테스트 | | tests/content-image-urls.test.js | 공개 본문 이미지 파생 URL·srcset·갤러리 sizes 단위 회귀 테스트 | | tests/markdown-block-context.test.js | 관리자 블록 설정 패널의 활성 블록 컨텍스트 단위 회귀 테스트 | +| tests/markdown-editor-history.test.js | 관리자 Markdown 에디터 undo/redo 히스토리 항목과 선택 범위 보정 단위 회귀 테스트 | | tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·포커스 대상·원문 줄 교체 삽입 단위 회귀 테스트 | | tests/markdown-block-parser.test.js | 공개 Markdown 본문 블록 파싱 단위 회귀 테스트 | | tests/markdown-fenced-meta.test.js | Markdown fenced 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 | diff --git a/docs/update.md b/docs/update.md index 29eea89..22d0a89 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.68 + +- 관리자 Markdown 에디터의 히스토리 항목 생성·본문 값 추출·선택 범위 보정 로직을 공통 helper로 분리. +- 에디터 undo/redo 히스토리의 레거시 문자열 항목과 선택 범위 보정 단위 테스트 추가. + ## v1.11.67 - 관리자 Markdown 에디터의 슬래시 명령 미디어 액션 판별·지연 commit 무시·명령 줄 교체 삽입 로직을 공통 helper로 분리. diff --git a/lib/markdown-editor-history.js b/lib/markdown-editor-history.js new file mode 100644 index 0000000..8dc6998 --- /dev/null +++ b/lib/markdown-editor-history.js @@ -0,0 +1,59 @@ +/** + * 에디터 히스토리 항목을 만든다. + * @param {string} value - 저장할 본문 + * @param {{ start?: number, end?: number }} selection - 현재 선택 위치 + * @returns {{ value: string, selectionStart: number, selectionEnd: number }} 히스토리 항목 + */ +export const createEditorHistoryEntry = (value, selection = {}) => { + const safeValue = String(value ?? '') + const start = Number(selection.start) + const end = Number(selection.end) + + return { + value: safeValue, + selectionStart: Math.min(Math.max(0, Number.isFinite(start) ? start : 0), safeValue.length), + selectionEnd: Math.min(Math.max(0, Number.isFinite(end) ? end : 0), safeValue.length) + } +} + +/** + * 히스토리 항목의 본문 값을 반환한다. + * @param {string|{ value?: string }} entry - 히스토리 항목 + * @returns {string|null} 본문 값 + */ +export const getEditorHistoryEntryValue = (entry) => { + if (typeof entry === 'string') { + return entry + } + + if (entry && typeof entry.value === 'string') { + return entry.value + } + + return null +} + +/** + * 히스토리 항목의 선택 위치를 반환한다. + * @param {string|{ value?: string, selectionStart?: number, selectionEnd?: number }} entry - 히스토리 항목 + * @param {string} value - 복원된 본문 + * @returns {{ start: number, end: number }|null} 선택 위치 + */ +export const getEditorHistoryEntrySelection = (entry, value) => { + if (!entry || typeof entry === 'string') { + return null + } + + const length = String(value ?? '').length + const start = Number(entry.selectionStart) + const end = Number(entry.selectionEnd) + + if (!Number.isFinite(start) || !Number.isFinite(end)) { + return null + } + + return { + start: Math.min(Math.max(0, start), length), + end: Math.min(Math.max(0, end), length) + } +} diff --git a/package-lock.json b/package-lock.json index e9f38c0..ffd4300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.67", + "version": "1.11.68", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.67", + "version": "1.11.68", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 0734cf3..b9bee16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.67", + "version": "1.11.68", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-editor-history.test.js b/tests/markdown-editor-history.test.js new file mode 100644 index 0000000..f2961ed --- /dev/null +++ b/tests/markdown-editor-history.test.js @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + createEditorHistoryEntry, + getEditorHistoryEntrySelection, + getEditorHistoryEntryValue +} from '../lib/markdown-editor-history.js' + +test('히스토리 항목은 본문 길이 안으로 선택 범위를 보정한다', () => { + assert.deepEqual(createEditorHistoryEntry('본문', { + start: -10, + end: 99 + }), { + value: '본문', + selectionStart: 0, + selectionEnd: 2 + }) +}) + +test('히스토리 항목은 유효하지 않은 선택 값을 0으로 처리한다', () => { + assert.deepEqual(createEditorHistoryEntry('abc', { + start: Number.NaN, + end: Infinity + }), { + value: 'abc', + selectionStart: 0, + selectionEnd: 0 + }) +}) + +test('문자열 히스토리 항목은 레거시 본문 값으로 읽는다', () => { + assert.equal(getEditorHistoryEntryValue('legacy'), 'legacy') + assert.equal(getEditorHistoryEntrySelection('legacy', 'legacy'), null) +}) + +test('객체 히스토리 항목의 본문과 선택 위치를 읽는다', () => { + const entry = { + value: 'abcdef', + selectionStart: 2, + selectionEnd: 4 + } + + assert.equal(getEditorHistoryEntryValue(entry), 'abcdef') + assert.deepEqual(getEditorHistoryEntrySelection(entry, 'abcdef'), { + start: 2, + end: 4 + }) +}) + +test('복원 선택 위치는 복원된 본문 길이 안으로 보정한다', () => { + assert.deepEqual(getEditorHistoryEntrySelection({ + value: 'abcdef', + selectionStart: 2, + selectionEnd: 99 + }, 'abc'), { + start: 2, + end: 3 + }) +})