에디터 히스토리 로직 분리

This commit is contained in:
2026-07-24 17:39:29 +09:00
parent 31cae6f90b
commit bad5a9081a
7 changed files with 135 additions and 62 deletions

View File

@@ -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

View File

@@ -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 블록 시작·종료 줄 탐색, 본문 수집, 메타 파싱 단위 회귀 테스트 |

View File

@@ -1,5 +1,10 @@
# 업데이트 이력
## v1.11.68
- 관리자 Markdown 에디터의 히스토리 항목 생성·본문 값 추출·선택 범위 보정 로직을 공통 helper로 분리.
- 에디터 undo/redo 히스토리의 레거시 문자열 항목과 선택 범위 보정 단위 테스트 추가.
## v1.11.67
- 관리자 Markdown 에디터의 슬래시 명령 미디어 액션 판별·지연 commit 무시·명령 줄 교체 삽입 로직을 공통 helper로 분리.

View File

@@ -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)
}
}

4
package-lock.json generated
View File

@@ -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",

View File

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

View File

@@ -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
})
})