코드 블록 슬래시와 되돌리기 수정

This commit is contained in:
2026-06-09 15:24:42 +09:00
parent 080be1ef15
commit 4b18ee78f0
8 changed files with 160 additions and 6 deletions

View File

@@ -104,6 +104,16 @@ const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
/** 되돌리기 히스토리 최대 보관 개수 */
const EDITOR_UNDO_LIMIT = 100
/** @type {import('vue').Ref<string[]>} 본문 되돌리기 스택 */
const editorUndoStack = ref([])
/** @type {import('vue').Ref<string[]>} 본문 다시 실행 스택 */
const editorRedoStack = ref([])
/** 히스토리 복원 중 변경 기록을 막는다. */
const isRestoringEditorHistory = ref(false)
/** 마지막 히스토리 기록 시각 */
let lastEditorHistoryRecordAt = 0
const mediaPickerAccept = computed(() => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return 'image/*'
@@ -378,6 +388,97 @@ const findCodeFenceRangeAtLine = (lines, targetLine) => {
return null
}
/**
* 지정 줄이 코드 펜스 본문 줄인지 확인한다.
* @param {string[]} lines - 마크다운 줄 목록
* @param {number} lineIndex - 줄 번호
* @returns {boolean}
*/
const isCodeFenceBodyLine = (lines, lineIndex) => {
for (let index = 0; index <= lineIndex; index += 1) {
if (!String(lines[index] ?? '').trim().startsWith('```')) {
continue
}
const closingLine = findNextFencedClosingLine(lines, index, '```')
if (lineIndex > index && (closingLine === null || lineIndex < closingLine)) {
return true
}
if (closingLine !== null) {
index = closingLine
}
}
return false
}
/**
* 현재 선택 위치에 맞춰 에디터 포커스를 복원한다.
* @returns {void}
*/
const restoreEditorFocusAfterHistory = () => {
const focusLine = activeLogicalLineIndex.value
if (activeMode.value === 'write') {
focusTextareaAtLine(focusLine)
return
}
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'auto')
})
}
/**
* 에디터 히스토리 항목을 기록한다.
* @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, 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 nextValue = sourceStack.value[sourceStack.value.length - 1]
if (typeof nextValue !== 'string') {
return
}
sourceStack.value = sourceStack.value.slice(0, -1)
targetStack.value = [...targetStack.value, markdownValue.value ?? ''].slice(-EDITOR_UNDO_LIMIT)
isRestoringEditorHistory.value = true
markdownValue.value = nextValue
lastEditorHistoryRecordAt = 0
nextTick(() => {
isRestoringEditorHistory.value = false
restoreEditorFocusAfterHistory()
})
}
/**
* 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다.
* @param {number} line - 원본 줄 번호
@@ -652,6 +753,12 @@ const syncWriteSlashState = () => {
}
const lines = (markdownValue.value ?? '').split('\n')
if (isCodeFenceBodyLine(lines, line)) {
onLiveSlashEnd({ sourceLine: line })
return
}
const parsed = parseSlashInput(lines[line] ?? '')
if (!parsed) {
@@ -816,6 +923,14 @@ watch(() => props.modelValue, () => {
refreshCaretLogicalLine()
})
watch(markdownValue, (nextValue, previousValue) => {
if (nextValue === previousValue) {
return
}
recordEditorHistory(previousValue)
})
watch(activeMode, (mode) => {
if (mode === 'write') {
nextTick(() => {
@@ -1007,6 +1122,22 @@ onMounted(() => {
return
}
const hasCommandModifier = event.metaKey || event.ctrlKey
const isUndoShortcut = hasCommandModifier
&& !event.altKey
&& !event.shiftKey
&& event.key.toLowerCase() === 'z'
const isRedoShortcut = hasCommandModifier
&& !event.altKey
&& (event.shiftKey && event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'y')
if (isUndoShortcut || isRedoShortcut) {
event.preventDefault()
event.stopPropagation()
restoreEditorHistory(isUndoShortcut ? 'undo' : 'redo')
return
}
if (liveSlashVisible.value) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
@@ -2128,6 +2259,13 @@ const onLiveSlashUpdate = (payload) => {
return
}
const lines = (markdownValue.value ?? '').split('\n')
if (isCodeFenceBodyLine(lines, payload.sourceLine)) {
onLiveSlashEnd({ sourceLine: payload.sourceLine })
return
}
liveSlashSourceLine.value = payload.sourceLine
liveSlashQuery.value = String(payload.query ?? '')
nextTick(updateLiveSlashMenuPosition)