관리자 본문 히스토리 상태 분리
This commit is contained in:
@@ -16,11 +16,6 @@ import {
|
||||
getMarkdownLineBodyPrefixLength,
|
||||
getSelectionOffsetInElement
|
||||
} from '../../lib/markdown-live-selection.js'
|
||||
import {
|
||||
createEditorHistoryEntry,
|
||||
getEditorHistoryEntrySelection,
|
||||
getEditorHistoryEntryValue
|
||||
} from '../../lib/markdown-editor-history.js'
|
||||
import {
|
||||
getEditorHistoryKeyboardAction,
|
||||
isEditorModeToggleShortcut,
|
||||
@@ -71,6 +66,7 @@ import { buildCodeFenceOpener } from '../../lib/markdown-code-block.js'
|
||||
import { buildToggleOpenerLine } from '../../lib/markdown-toggle.js'
|
||||
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
|
||||
import { useAdminMarkdownSlashMenu } from '../../composables/useAdminMarkdownSlashMenu.js'
|
||||
import { useAdminMarkdownHistory } from '../../composables/useAdminMarkdownHistory.js'
|
||||
import {
|
||||
buildDefaultUploadSizeLimits,
|
||||
DEFAULT_UPLOAD_MAX_FILE_COUNT,
|
||||
@@ -175,16 +171,6 @@ const {
|
||||
let liveSlashPreviewScrollElement = null
|
||||
/** @type {HTMLElement|null} 슬래시 메뉴 위치 갱신을 위해 구독한 작성 textarea */
|
||||
let liveSlashTextareaScrollElement = null
|
||||
/** 되돌리기 히스토리 최대 보관 개수 */
|
||||
const EDITOR_UNDO_LIMIT = 100
|
||||
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 되돌리기 스택 */
|
||||
const editorUndoStack = ref([])
|
||||
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 다시 실행 스택 */
|
||||
const editorRedoStack = ref([])
|
||||
/** 히스토리 복원 중 변경 기록을 막는다. */
|
||||
const isRestoringEditorHistory = ref(false)
|
||||
/** 마지막 히스토리 기록 시각 */
|
||||
let lastEditorHistoryRecordAt = 0
|
||||
const mediaPickerAccept = computed(() => getMediaPickerAcceptForTarget(mediaPickerTarget.value))
|
||||
|
||||
const mediaPickerKind = computed(() => getMediaPickerKindForTarget(mediaPickerTarget.value))
|
||||
@@ -732,54 +718,14 @@ const restoreEditorFocusAfterHistory = (selection = null) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 히스토리 항목을 기록한다.
|
||||
* @param {string} previousValue - 변경 전 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const recordEditorHistory = (previousValue) => {
|
||||
if (isRestoringEditorHistory.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = String(previousValue ?? '')
|
||||
const now = Date.now()
|
||||
const shouldGroup = now - lastEditorHistoryRecordAt < 900
|
||||
|
||||
if (!shouldGroup || !editorUndoStack.value.length) {
|
||||
editorUndoStack.value = [...editorUndoStack.value, createEditorHistoryEntry(value, lastSelectionState.value)].slice(-EDITOR_UNDO_LIMIT)
|
||||
}
|
||||
|
||||
editorRedoStack.value = []
|
||||
lastEditorHistoryRecordAt = now
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 본문 히스토리를 복원한다.
|
||||
* @param {'undo'|'redo'} direction - 복원 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreEditorHistory = (direction) => {
|
||||
const sourceStack = direction === 'undo' ? editorUndoStack : editorRedoStack
|
||||
const targetStack = direction === 'undo' ? editorRedoStack : editorUndoStack
|
||||
const nextEntry = sourceStack.value[sourceStack.value.length - 1]
|
||||
const nextValue = getEditorHistoryEntryValue(nextEntry)
|
||||
|
||||
if (typeof nextValue !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
sourceStack.value = sourceStack.value.slice(0, -1)
|
||||
targetStack.value = [...targetStack.value, createEditorHistoryEntry(markdownValue.value ?? '', lastSelectionState.value)].slice(-EDITOR_UNDO_LIMIT)
|
||||
isRestoringEditorHistory.value = true
|
||||
markdownValue.value = nextValue
|
||||
lastEditorHistoryRecordAt = 0
|
||||
|
||||
nextTick(() => {
|
||||
isRestoringEditorHistory.value = false
|
||||
restoreEditorFocusAfterHistory(getEditorHistoryEntrySelection(nextEntry, nextValue))
|
||||
})
|
||||
}
|
||||
const {
|
||||
recordEditorHistory,
|
||||
restoreEditorHistory
|
||||
} = useAdminMarkdownHistory({
|
||||
value: markdownValue,
|
||||
lastSelectionState,
|
||||
restoreFocusAfterHistory: restoreEditorFocusAfterHistory
|
||||
})
|
||||
|
||||
/**
|
||||
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
|
||||
|
||||
97
composables/useAdminMarkdownHistory.js
Normal file
97
composables/useAdminMarkdownHistory.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import {
|
||||
createEditorHistoryEntry,
|
||||
getEditorHistoryEntrySelection,
|
||||
getEditorHistoryEntryValue
|
||||
} from '../lib/markdown-editor-history.js'
|
||||
|
||||
const DEFAULT_EDITOR_UNDO_LIMIT = 100
|
||||
const DEFAULT_EDITOR_HISTORY_GROUP_DELAY_MS = 900
|
||||
|
||||
/**
|
||||
* 관리자 Markdown 에디터 히스토리 상태를 만든다.
|
||||
* @param {Object} options - 히스토리 옵션
|
||||
* @param {import('vue').Ref<string>} options.value - Markdown 본문 ref
|
||||
* @param {import('vue').Ref<{ start?: number, end?: number }>} options.lastSelectionState - 마지막 선택 위치
|
||||
* @param {(selection: { start: number, end: number }|null) => void} options.restoreFocusAfterHistory - 복원 후 포커스 콜백
|
||||
* @param {number} [options.undoLimit=100] - 히스토리 최대 개수
|
||||
* @param {number} [options.groupDelayMs=900] - 연속 입력 그룹 지연 시간
|
||||
* @returns {Object} 히스토리 상태와 이벤트
|
||||
*/
|
||||
export const useAdminMarkdownHistory = (options) => {
|
||||
const rawUndoLimit = Number(options.undoLimit)
|
||||
const rawGroupDelayMs = Number(options.groupDelayMs)
|
||||
const undoLimit = Math.max(1, Number.isFinite(rawUndoLimit) ? rawUndoLimit : DEFAULT_EDITOR_UNDO_LIMIT)
|
||||
const groupDelayMs = Math.max(0, Number.isFinite(rawGroupDelayMs) ? rawGroupDelayMs : DEFAULT_EDITOR_HISTORY_GROUP_DELAY_MS)
|
||||
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 되돌리기 스택 */
|
||||
const editorUndoStack = ref([])
|
||||
/** @type {import('vue').Ref<Array<string|{ value: string, selectionStart: number, selectionEnd: number }>>} 본문 다시 실행 스택 */
|
||||
const editorRedoStack = ref([])
|
||||
/** 히스토리 복원 중 변경 기록을 막는다. */
|
||||
const isRestoringEditorHistory = ref(false)
|
||||
/** 마지막 히스토리 기록 시각 */
|
||||
let lastEditorHistoryRecordAt = 0
|
||||
|
||||
/**
|
||||
* 에디터 히스토리 항목을 기록한다.
|
||||
* @param {string} previousValue - 변경 전 본문
|
||||
* @returns {void}
|
||||
*/
|
||||
const recordEditorHistory = (previousValue) => {
|
||||
if (isRestoringEditorHistory.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const value = String(previousValue ?? '')
|
||||
const now = Date.now()
|
||||
const shouldGroup = now - lastEditorHistoryRecordAt < groupDelayMs
|
||||
|
||||
if (!shouldGroup || !editorUndoStack.value.length) {
|
||||
editorUndoStack.value = [
|
||||
...editorUndoStack.value,
|
||||
createEditorHistoryEntry(value, options.lastSelectionState.value)
|
||||
].slice(-undoLimit)
|
||||
}
|
||||
|
||||
editorRedoStack.value = []
|
||||
lastEditorHistoryRecordAt = now
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 본문 히스토리를 복원한다.
|
||||
* @param {'undo'|'redo'} direction - 복원 방향
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreEditorHistory = (direction) => {
|
||||
const sourceStack = direction === 'undo' ? editorUndoStack : editorRedoStack
|
||||
const targetStack = direction === 'undo' ? editorRedoStack : editorUndoStack
|
||||
const nextEntry = sourceStack.value[sourceStack.value.length - 1]
|
||||
const nextValue = getEditorHistoryEntryValue(nextEntry)
|
||||
|
||||
if (typeof nextValue !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
sourceStack.value = sourceStack.value.slice(0, -1)
|
||||
targetStack.value = [
|
||||
...targetStack.value,
|
||||
createEditorHistoryEntry(options.value.value ?? '', options.lastSelectionState.value)
|
||||
].slice(-undoLimit)
|
||||
isRestoringEditorHistory.value = true
|
||||
options.value.value = nextValue
|
||||
lastEditorHistoryRecordAt = 0
|
||||
|
||||
nextTick(() => {
|
||||
isRestoringEditorHistory.value = false
|
||||
options.restoreFocusAfterHistory(getEditorHistoryEntrySelection(nextEntry, nextValue))
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
editorUndoStack,
|
||||
editorRedoStack,
|
||||
isRestoringEditorHistory,
|
||||
recordEditorHistory,
|
||||
restoreEditorHistory
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-07-27 v1.11.103 — 관리자 Markdown 히스토리 상태를 분리한다
|
||||
|
||||
관리자 Markdown 에디터의 undo/redo는 본문 값, 마지막 선택 위치, 빠른 입력 그룹화, 복원 중 기록 방지를 함께 관리한다. 이 상태를 거대 에디터 컴포넌트 안에 두면 DOM 포커스 복원 코드와 히스토리 스택 규칙이 섞이므로 `useAdminMarkdownHistory`로 분리한다. textarea와 라이브 편집 줄에 실제 포커스를 되돌리는 책임은 DOM 참조가 있는 에디터에 남겨 composable은 순수 상태 전환과 복원 콜백 호출만 담당한다.
|
||||
|
||||
## 2026-07-27 v1.11.102 — 공개 Markdown 인용 편집 이벤트를 분리한다
|
||||
|
||||
인용 블록 편집은 옵션 선언 줄 보존, 브라우저 자동 보정으로 생기는 `>. ` 접두 정리, 마지막 줄 아래 문단 이탈, Backspace 기반 일반 문단 복귀를 함께 처리한다. 이 흐름을 렌더러 내부에 남기면 공개 본문 표시와 관리자 라이브 편집 규칙이 계속 섞이므로 `useContentMarkdownQuotes`로 분리한다. 이전 줄 단위 인용 편집 함수는 현재 템플릿에서 사용하지 않으므로 제거해 실제 동작 경로만 테스트 대상으로 유지한다.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
|
||||
| composables/useAdminPostPublishState.js | 관리자 게시물 폼 발행·예약·발행취소 상태와 날짜 입력 상태 |
|
||||
| composables/useAdminMarkdownSlashMenu.js | 관리자 Markdown 에디터 슬래시 메뉴 상태·검색 결과·억제 줄·키보드 탐색 타이머 관리 |
|
||||
| composables/useAdminMarkdownHistory.js | 관리자 Markdown 에디터 undo/redo 스택·기록 그룹화·복원 중 상태 관리 |
|
||||
| composables/useContentMarkdownBlocks.js | 공개 Markdown 본문 파싱·제목 ID·인아티클 광고 렌더 블록·하단 삽입 줄·라이트박스 대상 목록 계산 |
|
||||
| composables/useContentMarkdownLightbox.js | 공개 Markdown 본문 단일 이미지·갤러리 통합 라이트박스 상태·좌우 이동·ESC 닫기·키보드 이벤트 연결 관리 |
|
||||
| composables/useContentMarkdownImageDrag.js | 관리자 라이브 Markdown 본문의 단일 이미지·갤러리 드래그 상태, drop target 표시, 병합·삽입·분리·정렬 이벤트 중개 |
|
||||
@@ -137,6 +138,7 @@
|
||||
| tests/markdown-slash-commands.test.js | 관리자 슬래시 명령 파싱·미디어 action·모달 종류·제외 줄 Set 갱신·하이라이트 인덱스 보정·방향키 순환·키보드 동작 분류·포커스 대상·textarea 선택 위치·원문 줄 교체 삽입 단위 회귀 테스트 |
|
||||
| tests/markdown-slash-menu-scroll.test.js | 관리자 슬래시 메뉴 선택 항목 scrollTop 보정 단위 테스트 |
|
||||
| tests/useAdminMarkdownSlashMenu.test.js | 관리자 Markdown 에디터 슬래시 메뉴 composable 상태 관리 단위 테스트 |
|
||||
| tests/useAdminMarkdownHistory.test.js | 관리자 Markdown 에디터 히스토리 composable의 기록·undo/redo·그룹화·보관 개수 단위 테스트 |
|
||||
| tests/useContentMarkdownBlocks.test.js | 공개 Markdown 블록 계산 composable의 파싱·제목 ID·광고 삽입·하단 삽입 줄 단위 테스트 |
|
||||
| tests/useContentMarkdownLightbox.test.js | 공개 Markdown 라이트박스 composable의 열기·좌우 순환·ESC 닫기·키보드 이벤트 해제 단위 테스트 |
|
||||
| tests/useContentMarkdownImageDrag.test.js | 공개 Markdown 라이브 이미지·갤러리 드래그 composable의 병합·삽입·분리·정렬 단위 테스트 |
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
- Markdown 리스트 마커는 항목이 여러 줄로 줄바꿈되어도 항목 전체 높이 중앙이 아니라 첫 줄 기준으로 정렬한다.
|
||||
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다. 표 헤더와 셀 내부의 인라인 링크·굵게·기울임·인라인 코드를 렌더링하고, 셀 내부 `<br>` 또는 `<br/>`은 줄바꿈으로 표시한다. 링크 텍스트 안의 `**굵게**` 같은 중첩 인라인 문법도 렌더링한다.
|
||||
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다. 인용 본문 안의 `::red[**텍스트**]` 같은 색상·굵게·링크 등 인라인 문법은 일반 문단과 같은 규칙으로 렌더링한다.
|
||||
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 단일 이미지는 전체 너비(자동)·갤러리 2장 1칸(356px)·갤러리 3장 1칸(235px)·갤러리 3장 2칸(477px)·직접 입력 셀렉트로 표시 너비를 고를 수 있으며 저장 시 이미지 마크다운 뒤에 `{size=숫자}` 옵션을 붙인다. 공개 본문은 이 값을 최대 너비로 적용하고 원본 비율은 유지한다. 직접 입력은 120~1600px 범위로 저장하되 입력 중에는 범위 보정을 즉시 강제하지 않는다. 콜아웃은 제목·아이콘 표시 ON/OFF 토글·아이콘·배경색, 코드 블록은 언어 셀렉트·줄번호 표시 ON/OFF 토글, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘 입력·프리셋은 아이콘 표시가 꺼진 상태에서 흐린 비활성 영역으로 표시하고, 토글을 켠 뒤에만 조작한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 본문 값과 함께 선택 위치를 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다. 인용 편집 중 브라우저 자동 목록 보정으로 `>. ` 접두가 섞이면 커밋 시 정리한다.
|
||||
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 단일 이미지는 전체 너비(자동)·갤러리 2장 1칸(356px)·갤러리 3장 1칸(235px)·갤러리 3장 2칸(477px)·직접 입력 셀렉트로 표시 너비를 고를 수 있으며 저장 시 이미지 마크다운 뒤에 `{size=숫자}` 옵션을 붙인다. 공개 본문은 이 값을 최대 너비로 적용하고 원본 비율은 유지한다. 직접 입력은 120~1600px 범위로 저장하되 입력 중에는 범위 보정을 즉시 강제하지 않는다. 콜아웃은 제목·아이콘 표시 ON/OFF 토글·아이콘·배경색, 코드 블록은 언어 셀렉트·줄번호 표시 ON/OFF 토글, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 콜아웃 아이콘 입력·프리셋은 아이콘 표시가 꺼진 상태에서 흐린 비활성 영역으로 표시하고, 토글을 켠 뒤에만 조작한다. 코드 블록 줄번호는 기본 숨김이며, 켠 경우에만 선언 줄에 `line-numbers`를 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 인용·콜아웃·토글 본문은 라이브 모드에서 굵게·링크·색상 같은 인라인 문법과 기본 불릿/번호 목록을 표시한다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html`은 `:::html`, `/link`는 `:::link`, `/iframe`은 `:::iframe` fenced 블록을 삽입한다. 소스·라이브 모드 모두 텍스트를 선택한 뒤 `Cmd/Ctrl+K`를 누르면 `[선택 텍스트](https://)` 인라인 링크 마크다운을 삽입하고 URL 부분을 선택한다. 에디터 되돌리기·다시 실행은 `useAdminMarkdownHistory`에서 본문 값과 선택 위치를 함께 복원해 줄 앞 이동을 줄인다. 라이브 모드에서 위·아래 방향키로 인접 블록을 이동하면 커서가 화면 밖으로 벗어날 때 편집 스크롤 영역도 함께 이동한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다. 인용 편집 중 브라우저 자동 목록 보정으로 `>. ` 접두가 섞이면 커밋 시 정리한다.
|
||||
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 공개 회원 로그인 세션의 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다. 별도 관리자 세션만 있거나 비로그인 상태에서는 표시하지 않는다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.11.103
|
||||
|
||||
- 관리자 Markdown 에디터의 undo/redo 스택·기록 그룹화·복원 중 상태 관리를 `useAdminMarkdownHistory` composable로 분리.
|
||||
- 히스토리 복원 후 textarea·라이브 편집 포커스 복원 콜백은 에디터 컴포넌트에 남겨 DOM 책임과 상태 책임을 분리.
|
||||
- 히스토리 기록, undo/redo 복원, 빠른 입력 그룹화, undo 보관 개수 제한 단위 테스트 추가.
|
||||
|
||||
## v1.11.102
|
||||
|
||||
- 공개 Markdown 렌더러의 라이브 인용 블록 커밋·아래 이탈·일반 문단 복귀 이벤트를 `useContentMarkdownQuotes` composable로 분리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.102",
|
||||
"version": "1.11.103",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.102",
|
||||
"version": "1.11.103",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.11.102",
|
||||
"version": "1.11.103",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
116
tests/useAdminMarkdownHistory.test.js
Normal file
116
tests/useAdminMarkdownHistory.test.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useAdminMarkdownHistory } from '../composables/useAdminMarkdownHistory.js'
|
||||
|
||||
/**
|
||||
* 테스트용 관리자 Markdown 히스토리 상태를 만든다.
|
||||
* @param {Object} options - 테스트 옵션
|
||||
* @returns {{ value: import('vue').Ref<string>, lastSelectionState: import('vue').Ref<Object>, restoredSelections: Array<Object|null>, history: Object }}
|
||||
*/
|
||||
const createHistoryState = (options = {}) => {
|
||||
const value = ref(options.value ?? '')
|
||||
const lastSelectionState = ref(options.selection ?? { start: 0, end: 0 })
|
||||
const restoredSelections = []
|
||||
const history = useAdminMarkdownHistory({
|
||||
value,
|
||||
lastSelectionState,
|
||||
restoreFocusAfterHistory: (selection) => {
|
||||
restoredSelections.push(selection)
|
||||
},
|
||||
undoLimit: options.undoLimit,
|
||||
groupDelayMs: options.groupDelayMs
|
||||
})
|
||||
|
||||
return {
|
||||
value,
|
||||
lastSelectionState,
|
||||
restoredSelections,
|
||||
history
|
||||
}
|
||||
}
|
||||
|
||||
test('관리자 Markdown 히스토리는 이전 값을 undo 스택에 기록한다', () => {
|
||||
const { history } = createHistoryState({
|
||||
selection: { start: 1, end: 3 },
|
||||
groupDelayMs: 0
|
||||
})
|
||||
|
||||
history.recordEditorHistory('이전')
|
||||
|
||||
assert.deepEqual(history.editorUndoStack.value, [{
|
||||
value: '이전',
|
||||
selectionStart: 1,
|
||||
selectionEnd: 2
|
||||
}])
|
||||
assert.deepEqual(history.editorRedoStack.value, [])
|
||||
})
|
||||
|
||||
test('관리자 Markdown 히스토리 undo는 현재 값을 redo에 넣고 선택 위치를 복원한다', async () => {
|
||||
const {
|
||||
value,
|
||||
lastSelectionState,
|
||||
restoredSelections,
|
||||
history
|
||||
} = createHistoryState({
|
||||
value: '현재',
|
||||
selection: { start: 2, end: 2 },
|
||||
groupDelayMs: 0
|
||||
})
|
||||
|
||||
history.recordEditorHistory('이전')
|
||||
lastSelectionState.value = { start: 1, end: 1 }
|
||||
history.restoreEditorHistory('undo')
|
||||
await nextTick()
|
||||
|
||||
assert.equal(value.value, '이전')
|
||||
assert.deepEqual(history.editorUndoStack.value, [])
|
||||
assert.deepEqual(history.editorRedoStack.value, [{
|
||||
value: '현재',
|
||||
selectionStart: 1,
|
||||
selectionEnd: 1
|
||||
}])
|
||||
assert.deepEqual(restoredSelections, [{ start: 2, end: 2 }])
|
||||
})
|
||||
|
||||
test('관리자 Markdown 히스토리 redo는 redo 스택 값을 다시 적용한다', async () => {
|
||||
const { value, history } = createHistoryState({
|
||||
value: '현재',
|
||||
groupDelayMs: 0
|
||||
})
|
||||
|
||||
history.recordEditorHistory('이전')
|
||||
history.restoreEditorHistory('undo')
|
||||
await nextTick()
|
||||
history.restoreEditorHistory('redo')
|
||||
await nextTick()
|
||||
|
||||
assert.equal(value.value, '현재')
|
||||
assert.deepEqual(history.editorRedoStack.value, [])
|
||||
assert.equal(history.editorUndoStack.value.length, 1)
|
||||
})
|
||||
|
||||
test('관리자 Markdown 히스토리는 빠른 연속 기록을 하나의 undo 항목으로 묶는다', () => {
|
||||
const { history } = createHistoryState({
|
||||
groupDelayMs: 60_000
|
||||
})
|
||||
|
||||
history.recordEditorHistory('첫 값')
|
||||
history.recordEditorHistory('두 번째 값')
|
||||
|
||||
assert.equal(history.editorUndoStack.value.length, 1)
|
||||
assert.equal(history.editorUndoStack.value[0].value, '첫 값')
|
||||
})
|
||||
|
||||
test('관리자 Markdown 히스토리는 undo 보관 개수를 제한한다', () => {
|
||||
const { history } = createHistoryState({
|
||||
undoLimit: 2,
|
||||
groupDelayMs: 0
|
||||
})
|
||||
|
||||
history.recordEditorHistory('1')
|
||||
history.recordEditorHistory('2')
|
||||
history.recordEditorHistory('3')
|
||||
|
||||
assert.deepEqual(history.editorUndoStack.value.map((entry) => entry.value), ['2', '3'])
|
||||
})
|
||||
Reference in New Issue
Block a user