From 928b8446b4810725e6e3996e76c91b47f05b69d4 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 5 Jun 2026 15:27:06 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=84=A0=ED=83=9D=C2=B7=EC=BD=9C=EC=95=84=EC=9B=83?= =?UTF-8?q?=C2=B7=EC=9D=B8=EC=9A=A9=20=EC=95=88=EC=A0=95=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EC=97=AC=EB=B0=B1=20=EB=B3=B4=EC=A0=95=20?= =?UTF-8?q?(v1.5.70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다. Co-authored-by: Cursor --- components/admin/AdminMarkdownEditor.vue | 33 +- .../content/ContentMarkdownCalloutEditor.vue | 1 + .../content/ContentMarkdownEditableInline.vue | 497 +++++++++++++++--- .../content/ContentMarkdownRenderer.vue | 199 ++++++- components/site/RightSidebar.vue | 8 +- docs/changelog.md | 47 ++ docs/history.md | 40 ++ docs/map.md | 5 +- docs/spec.md | 3 +- docs/update.md | 48 ++ lib/markdown-inline.js | 203 ++++++- lib/markdown-live-selection.js | 489 +++++++++++++++++ package.json | 2 +- 13 files changed, 1458 insertions(+), 117 deletions(-) create mode 100644 lib/markdown-live-selection.js diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index d4456bc..128abf9 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -1804,6 +1804,19 @@ const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) = replaceLineRange(startLine, resolveCurrentFencedBlockEndLine(startLine, endLine), replacementLines, false) } +/** + * 라이브 모드 교차 선택 삭제 결과를 본문에 반영한다. + * @param {{ value: string }} payload - 갱신된 마크다운 + * @returns {void} + */ +const onPreviewContentReplace = ({ value }) => { + if (typeof value !== 'string') { + return + } + + markdownValue.value = value +} + /** * 라이브 모드 하단에서 새 문단 줄을 추가한다. * @returns {void} @@ -1862,6 +1875,23 @@ const onPreviewDeleteLine = (lineIndex) => { * @returns {void} */ const handlePreviewKeydownCapture = (event) => { + const target = event.target + const isSelectAllShortcut = (event.metaKey || event.ctrlKey) + && !event.shiftKey + && !event.altKey + && event.key.toLowerCase() === 'a' + + if (isSelectAllShortcut) { + if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) { + return + } + + event.preventDefault() + event.stopPropagation() + previewRendererRef.value?.selectAllLiveDocument() + return + } + const isDeleteShortcut = (event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k' @@ -1870,8 +1900,6 @@ const handlePreviewKeydownCapture = (event) => { return } - const target = event.target - if (target instanceof HTMLElement && target.closest('[contenteditable="true"]')) { return } @@ -3093,6 +3121,7 @@ const handleKeydown = (event) => { @extract-gallery-image="onPreviewExtractGalleryImage" @remove-gallery-image="onPreviewRemoveGalleryImage" @block-content-change="onPreviewBlockContentChange" + @content-replace="onPreviewContentReplace" @append-paragraph="onPreviewAppendParagraph" @insert-after-line="onPreviewInsertAfterLine" @delete-line="onPreviewDeleteLine" diff --git a/components/content/ContentMarkdownCalloutEditor.vue b/components/content/ContentMarkdownCalloutEditor.vue index ce13f14..ad1bd95 100644 --- a/components/content/ContentMarkdownCalloutEditor.vue +++ b/components/content/ContentMarkdownCalloutEditor.vue @@ -111,6 +111,7 @@ const onBodyInput = (payload) => { enter-mode="multiline" plain-text arrow-exit-creates-line + preserve-empty-line-on-full-delete :source-line="bodySourceLine" :source-line-count="bodyLines.length" :model-value="modelValue" diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue index 9dc14f4..356ca96 100644 --- a/components/content/ContentMarkdownEditableInline.vue +++ b/components/content/ContentMarkdownEditableInline.vue @@ -5,6 +5,12 @@ import { readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js' +import { + isEditableElementFullySelected, + isLiveSelectionDeleteKey, + LIVE_SELECTION_BRIDGE_KEY, + selectEditableElementContents +} from '../../lib/markdown-live-selection.js' import { parseSlashInput } from '../../lib/markdown-slash-commands.js' const props = defineProps({ @@ -88,6 +94,16 @@ const props = defineProps({ plainText: { type: Boolean, default: false + }, + /** 전체 선택 삭제 시 비어 있는 원본 줄을 보존할지 여부 */ + preserveEmptyLineOnFullDelete: { + type: Boolean, + default: false + }, + /** 전체 선택 삭제 후 남길 빈 원본 줄 */ + emptyMarkdownLine: { + type: String, + default: '' } }) @@ -111,11 +127,23 @@ const rootRef = ref(null) const isFocused = ref(false) const suppressBlurCommit = ref(false) const splitLock = ref(false) -/** 조합 중 Enter 후 compositionend에서 분리할지 */ -const pendingSplitAfterComposition = ref(false) +/** 부모가 병합·분리 등 구조 변경을 반영한 시각(ms) */ +const lastStructuralModelSyncAt = ref(0) +/** 한글 등 IME 조합 중인지 여부 */ +const isComposingText = ref(false) +/** @type {import('vue').Ref<'split'|'insert-below'|'newline'|'focus-next'|'blur'|null>} */ +const pendingComposedEnterAction = ref(null) +/** 마지막 IME 조합 종료 시각(ms) */ +const lastCompositionEndAt = ref(0) +/** 조합 확정 Enter 후 블록 동작을 실행한 마지막 시각(ms) */ +const lastComposedEnterHandledAt = ref(0) const showingRaw = ref(false) +/** 마지막 블록 전체 선택 시각(ms) */ +const lastBlockSelectAllAt = ref(0) let cleanupComposedEnterSuppressor = null +const liveSelectionBridge = inject(LIVE_SELECTION_BRIDGE_KEY, null) + /** @returns {string} Enter 동작 모드 */ const resolvedEnterMode = computed(() => { if (props.enterMode !== 'none') { @@ -140,10 +168,13 @@ const plainTextToEditorText = (value) => String(value ?? '') /** * 편집 영역 HTML을 동기화한다. + * @param {{ force?: boolean }} [options] - 포커스 중에도 강제 동기화할지 * @returns {void} */ -const syncEditorHtml = () => { - if (!rootRef.value || isFocused.value) { +const syncEditorHtml = (options = {}) => { + const force = options.force === true + + if (!rootRef.value || (isFocused.value && !force)) { return } @@ -160,22 +191,6 @@ const syncEditorHtml = () => { rootRef.value.innerHTML = toEditorHtml() } -watch(() => [props.modelValue, props.rawLine], () => { - if (!isFocused.value) { - showingRaw.value = false - syncEditorHtml() - return - } - - if (showingRaw.value) { - if (props.rawLine && rootRef.value && rootRef.value.textContent !== props.rawLine) { - rootRef.value.textContent = props.rawLine - } - - return - } -}) - onMounted(() => { syncEditorHtml() }) @@ -247,9 +262,44 @@ const readEditorValue = () => { return rootRef.value.textContent ?? '' } - return readEditableTextFromElement(rootRef.value) + return readEditableTextFromElement(rootRef.value, { trimEnd: !props.plainText }) } +/** + * 부모 modelValue와 편집 DOM이 어긋났는지 확인한다. + * @returns {boolean} + */ +const isEditorOutOfSyncWithModel = () => { + const model = String(props.modelValue ?? '') + + if (showingRaw.value) { + return (rootRef.value?.textContent ?? '') !== String(props.rawLine ?? '') + } + + return readEditorValue() !== model +} + +watch(() => [props.modelValue, props.rawLine], () => { + if (!isFocused.value) { + showingRaw.value = false + syncEditorHtml() + return + } + + if (showingRaw.value) { + if (props.rawLine && rootRef.value && rootRef.value.textContent !== props.rawLine) { + rootRef.value.textContent = props.rawLine + } + + return + } + + if ((suppressBlurCommit.value || splitLock.value) && isEditorOutOfSyncWithModel()) { + lastStructuralModelSyncAt.value = Date.now() + syncEditorHtml({ force: true }) + } +}) + const onBlur = () => { isFocused.value = false @@ -257,7 +307,20 @@ const onBlur = () => { return } + const modelValue = String(props.modelValue ?? '') const nextValue = readEditorValue() + + if ( + Date.now() - lastStructuralModelSyncAt.value < 500 + && modelValue + && nextValue !== modelValue + && nextValue.startsWith(modelValue) + && nextValue.length > modelValue.length + ) { + syncEditorHtml() + return + } + const changed = showingRaw.value ? nextValue !== props.rawLine : nextValue !== props.modelValue @@ -425,6 +488,48 @@ const insertTextAtSelection = (text) => { return true } +/** + * plain text 편집 영역에서 현재 선택 범위를 텍스트로 교체한다. + * @param {string} text - 삽입할 텍스트 + * @returns {boolean} 삽입 여부 + */ +const replacePlainTextSelection = (text) => { + if (!import.meta.client || !rootRef.value) { + return false + } + + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0) { + return false + } + + const range = selection.getRangeAt(0) + + if (!rootRef.value.contains(range.commonAncestorContainer)) { + return false + } + + const startRange = document.createRange() + startRange.setStart(range.startContainer, range.startOffset) + startRange.collapse(true) + + const endRange = document.createRange() + endRange.setStart(range.endContainer, range.endOffset) + endRange.collapse(true) + + const startOffset = getEditableCaretOffset(rootRef.value, startRange) + const endOffset = getEditableCaretOffset(rootRef.value, endRange) + const from = Math.min(startOffset, endOffset) + const to = Math.max(startOffset, endOffset) + const value = readEditorValue() + const nextValue = `${value.slice(0, from)}${text}${value.slice(to)}` + + rootRef.value.textContent = nextValue + setEditableCaretOffset(rootRef.value, from + text.length) + return true +} + /** * 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함). * @returns {boolean} @@ -447,7 +552,7 @@ const isCaretAtLogicalStart = () => { * 커서가 줄의 논리적 맨 끝인지 확인한다. * @returns {boolean} */ -const isCaretAtLogicalEnd = () => isCaretAtEdge('end') +const isCaretAtLogicalEnd = () => isCaretAtEdge('end') || getCaretTextOffset() >= readEditorValue().length /** * 커서가 위치한 시각 줄 정보를 반환한다. @@ -726,6 +831,22 @@ const splitAtCaret = () => { emit('split', readCaretSplit()) } +/** + * 줄 삭제·병합 같은 구조 변경 직후 stale DOM 커밋을 잠시 차단한다. + * @returns {void} + */ +const beginStructuralEdit = () => { + if (!import.meta.client) { + return + } + + suppressBlurCommit.value = true + lastStructuralModelSyncAt.value = Date.now() + window.setTimeout(() => { + suppressBlurCommit.value = false + }, 400) +} + /** * Enter 처리(분리·아래 삽입)를 한 번만 실행한다. * @param {'split'|'insert-below'} action - 동작 @@ -738,6 +859,7 @@ const scheduleEnterAction = (action) => { splitLock.value = true suppressBlurCommit.value = true + lastStructuralModelSyncAt.value = Date.now() if (action === 'split') { splitAtCaret() @@ -749,10 +871,73 @@ const scheduleEnterAction = (action) => { splitLock.value = false window.setTimeout(() => { suppressBlurCommit.value = false - }, 180) + }, 400) }) } +/** + * Enter 모드에 맞는 실행 동작을 반환한다. + * @param {string} enterMode - Enter 모드 + * @returns {'split'|'insert-below'|'newline'|'focus-next'|'blur'|null} 실행 동작 + */ +const getEnterAction = (enterMode) => { + if (enterMode === 'split-paragraph') { + return 'split' + } + + if (enterMode === 'insert-below') { + return 'insert-below' + } + + if (enterMode === 'multiline') { + return 'newline' + } + + if (enterMode === 'focus-next') { + return 'focus-next' + } + + if (enterMode === 'none') { + return 'blur' + } + + return null +} + +/** + * 저장된 Enter 동작을 실행한다. + * @param {'split'|'insert-below'|'newline'|'focus-next'|'blur'} action - 실행 동작 + * @returns {void} + */ +const runEnterAction = (action) => { + if (action === 'split' || action === 'insert-below') { + scheduleEnterAction(action) + return + } + + if (action === 'newline') { + const inserted = props.plainText + ? replacePlainTextSelection('\n') + : insertTextAtSelection('\n') + + if (!inserted) { + return + } + + nextTick(onEditorInput) + return + } + + if (action === 'focus-next') { + emit('enter-advance') + return + } + + if (action === 'blur') { + rootRef.value?.blur() + } +} + /** * 조합 종료 직후 브라우저가 다시 전달하는 Enter를 한 번 차단한다. * @returns {void} @@ -845,6 +1030,135 @@ const placeCursorAfterPrefix = () => { } } +/** + * Shift 선택이 인접 블록으로 확장되어야 하는지 확인한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {boolean} + */ +const shouldExtendSelectionAcrossBlocks = (event) => { + if (!liveSelectionBridge || props.sourceLine === null) { + return false + } + + if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) { + return false + } + + const key = event.key + const lineContext = getCaretLineContext() + const isMultilineEditor = resolvedEnterMode.value === 'multiline' + + if (key === 'ArrowDown') { + return isMultilineEditor + ? lineContext.isLastLine + : true + } + + if (key === 'ArrowUp') { + return isMultilineEditor + ? lineContext.isFirstLine + : true + } + + if (key === 'ArrowLeft') { + return isCaretAtLogicalStart() + } + + if (key === 'ArrowRight') { + return isCaretAtLogicalEnd() + } + + return false +} + +/** + * Shift 범위 선택 등 블록 내부 기본 텍스트 선택 동작인지 확인한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {boolean} + */ +const shouldPreserveNativeTextSelection = (event) => { + if (event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (shouldExtendSelectionAcrossBlocks(event)) { + return false + } + + return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key) + } + + return false +} + +/** + * Cmd/Ctrl+A 단계 선택을 처리한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {boolean} 처리 여부 + */ +const handleSelectAllShortcut = (event) => { + if (!rootRef.value) { + return false + } + + const hasCommandModifier = event.metaKey || event.ctrlKey + + if (!hasCommandModifier || event.shiftKey || event.altKey || event.key.toLowerCase() !== 'a') { + return false + } + + event.preventDefault() + event.stopPropagation() + + const now = Date.now() + const isBlockFullySelected = isEditableElementFullySelected(rootRef.value) + + if (isBlockFullySelected && now - lastBlockSelectAllAt.value < 1200 && liveSelectionBridge) { + liveSelectionBridge.selectDocument() + lastBlockSelectAllAt.value = 0 + return true + } + + selectEditableElementContents(rootRef.value) + lastBlockSelectAllAt.value = now + return true +} + +/** + * Shift 선택을 인접 블록으로 확장한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {boolean} 처리 여부 + */ +const handleCrossBlockSelection = (event) => { + if (!shouldExtendSelectionAcrossBlocks(event) || !liveSelectionBridge) { + return false + } + + const lineContext = getCaretLineContext() + let direction = 0 + + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + direction = 1 + } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + direction = -1 + } + + if (!direction) { + return false + } + + event.preventDefault() + event.stopPropagation() + + const resolvedSourceLine = Number(rootRef.value?.getAttribute('data-source-line')) + + liveSelectionBridge.extendSelection({ + sourceLine: Number.isInteger(resolvedSourceLine) ? resolvedSourceLine : props.sourceLine, + direction, + column: lineContext.column, + navigationScope: props.navigationScope + }) + + return true +} + /** * 키보드 입력 처리 * @param {KeyboardEvent} event - 키보드 이벤트 @@ -857,6 +1171,24 @@ const onKeydown = (event) => { return } + if (handleSelectAllShortcut(event)) { + return + } + + if (isLiveSelectionDeleteKey(event) && liveSelectionBridge?.deleteSelection?.()) { + event.preventDefault() + event.stopPropagation() + return + } + + if (handleCrossBlockSelection(event)) { + return + } + + if (shouldPreserveNativeTextSelection(event)) { + return + } + if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === 'k') { event.preventDefault() event.stopPropagation() @@ -873,6 +1205,7 @@ const onKeydown = (event) => { event.preventDefault() event.stopPropagation() + beginStructuralEdit() emit('delete-line', sourceLine) } @@ -895,6 +1228,7 @@ const onKeydown = (event) => { event.preventDefault() event.stopPropagation() + beginStructuralEdit() emit('merge-with-previous', buildInsertBelowPayload()) return } @@ -919,6 +1253,7 @@ const onKeydown = (event) => { event.preventDefault() event.stopPropagation() + beginStructuralEdit() emit('delete-line', sourceLine) return } @@ -968,7 +1303,7 @@ const onKeydown = (event) => { return } - if (!hasCommandModifier && !event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { + if (!hasCommandModifier && !event.altKey && !event.shiftKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) { if (event.isComposing || event.keyCode === 229) { return } @@ -993,14 +1328,14 @@ const onKeydown = (event) => { return } - if (!hasCommandModifier && !event.altKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) { + if (!hasCommandModifier && !event.altKey && !event.shiftKey && event.key === 'ArrowLeft' && isCaretAtLogicalStart()) { event.preventDefault() event.stopPropagation() navigateToAdjacentBlock(-1, 0, 'block-end') return } - if (!hasCommandModifier && !event.altKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) { + if (!hasCommandModifier && !event.altKey && !event.shiftKey && event.key === 'ArrowRight' && isCaretAtLogicalEnd()) { event.preventDefault() event.stopPropagation() navigateToAdjacentBlock(1, 0, 'block-start') @@ -1008,8 +1343,11 @@ const onKeydown = (event) => { } const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value + const enterAction = getEnterAction(enterMode) + const isComposingEnter = event.key === 'Enter' + && (event.isComposing || event.keyCode === 229 || isComposingText.value) - if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) { + if (event.key === 'Enter' && !event.shiftKey && !isComposingEnter && parseSlashInput(readEditorValue())) { event.preventDefault() event.stopPropagation() event.stopImmediatePropagation?.() @@ -1020,77 +1358,83 @@ const onKeydown = (event) => { return } - if (event.key === 'Enter' && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) { + if (event.key === 'Enter' && enterAction) { event.preventDefault() event.stopPropagation() - if (event.isComposing || event.keyCode === 229) { - pendingSplitAfterComposition.value = true + if (isComposingEnter) { + pendingComposedEnterAction.value = enterAction return } - pendingSplitAfterComposition.value = false - scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below') - return - } - - if (event.key === 'Enter' && enterMode === 'multiline') { - event.preventDefault() - event.stopPropagation() - - if (event.isComposing || event.keyCode === 229) { - return - } - - if (!insertTextAtSelection('\n')) { - return - } - - nextTick(onEditorInput) - return - } - - if (event.key === 'Enter' && enterMode === 'focus-next') { - event.preventDefault() - event.stopPropagation() - - if (event.isComposing || event.keyCode === 229) { - return - } - - emit('enter-advance') - return - } - - if (event.key === 'Enter' && enterMode === 'none') { - event.preventDefault() - event.stopPropagation() - rootRef.value?.blur() + pendingComposedEnterAction.value = null + runEnterAction(enterAction) } } /** - * 한글 등 IME 조합 종료 후 Enter 처리 + * 한글 등 IME 조합 시작 상태를 기록한다. + * @returns {void} + */ +const onCompositionStart = () => { + isComposingText.value = true +} + +/** + * 한글 등 IME 조합 종료 후 저장된 Enter 동작을 실행한다. * @returns {void} */ const onCompositionEnd = () => { - if (!pendingSplitAfterComposition.value) { - return - } + isComposingText.value = false + lastCompositionEndAt.value = Date.now() - pendingSplitAfterComposition.value = false - const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value + const action = pendingComposedEnterAction.value + pendingComposedEnterAction.value = null - if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') { + if (!action) { return } nextTick(() => { suppressNextComposedEnterGlobally() - scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below') + lastComposedEnterHandledAt.value = Date.now() + runEnterAction(action) }) } +/** + * 일부 IME는 조합 확정 Enter의 keydown을 전달하지 않고 keyup만 남긴다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {void} + */ +const onKeyup = (event) => { + const now = Date.now() + + if ( + event.key !== 'Enter' + || event.metaKey + || event.ctrlKey + || event.altKey + || now - lastCompositionEndAt.value > 500 + || now - lastComposedEnterHandledAt.value < 300 + ) { + return + } + + const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value + const action = getEnterAction(enterMode) + + if (!action) { + return + } + + event.preventDefault() + event.stopPropagation() + suppressNextComposedEnterGlobally() + lastComposedEnterHandledAt.value = now + runEnterAction(action) +} + /** * 외부에서 포커스·커서를 둔다. * @param {'start'|'end'} position - 커서 위치 @@ -1123,6 +1467,7 @@ defineExpose({ focusEditor, readEditorValue }) ]" :data-source-line="sourceLine ?? undefined" :data-source-line-end="sourceLine !== null ? sourceLine + Math.max(1, sourceLineCount) - 1 : undefined" + :data-empty-markdown-line="preserveEmptyLineOnFullDelete ? emptyMarkdownLine : undefined" contenteditable="true" spellcheck="true" @focus="onFocus" @@ -1130,6 +1475,8 @@ defineExpose({ focusEditor, readEditorValue }) @input="onEditorInput" @paste="onPaste" @keydown="onKeydown" + @keyup="onKeyup" + @compositionstart="onCompositionStart" @compositionend="onCompositionEnd" /> diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 2ca21a5..0305f55 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -6,6 +6,15 @@ import { parseImageMarkdownLine } from '../../lib/markdown-image.js' import { parseInlineSegments, readEditableTextFromElement, setEditableCaretOffset } from '../../lib/markdown-inline.js' +import { + applyLiveSelectionDelete, + collapseLiveSelection, + extendSelectionAcrossBlocks, + getSelectableEditableElements, + isLiveSelectionDeleteKey, + LIVE_SELECTION_BRIDGE_KEY, + selectAllEditableElements +} from '../../lib/markdown-live-selection.js' import { appendTextToMarkdownLine, getAppendTextForMerge, @@ -69,7 +78,8 @@ const emit = defineEmits([ 'line-blur', 'slash-update', 'slash-end', - 'slash-apply' + 'slash-apply', + 'content-replace' ]) const BLANK_PARAGRAPH_MARKER = '' @@ -903,9 +913,29 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca const line = getMarkdownLine(lineIndex) const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim()) const elementSourceLine = Number(element.getAttribute('data-source-line')) - const isRangedLine = Number.isInteger(elementSourceLine) && elementSourceLine !== lineIndex + const elementSourceLineEnd = Number(element.getAttribute('data-source-line-end')) + const hasSourceLineRange = Number.isInteger(elementSourceLine) && Number.isInteger(elementSourceLineEnd) + const spansMultipleLines = hasSourceLineRange && elementSourceLineEnd > elementSourceLine + const isWithinSourceLineRange = hasSourceLineRange + && lineIndex >= elementSourceLine + && lineIndex <= elementSourceLineEnd + const useMultilineCaret = spansMultipleLines && isWithinSourceLineRange - if (!isRangedLine && (isBlankMarker || !line.trim())) { + /** + * 멀티라인 편집 영역에서 원본 줄에 해당하는 텍스트 오프셋을 반환한다. + * @returns {number} 루트 기준 오프셋 + */ + const getCaretOffsetForSourceLine = () => { + const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element)) + const textLines = text.length ? text.split('\n') : [''] + const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1)) + + return textLines + .slice(0, targetLineIndex) + .reduce((sum, textLine) => sum + textLine.length + 1, 0) + } + + if (!useMultilineCaret && (isBlankMarker || !line.trim())) { if (element.getAttribute('contenteditable') === 'true') { element.textContent = '' element.innerHTML = '' @@ -920,15 +950,8 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca } if (typeof caretOffset === 'number' && caretOffset >= 0) { - if (isRangedLine && Number.isInteger(elementSourceLine)) { - const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element)) - const textLines = text.length ? text.split('\n') : [''] - const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1)) - const lineOffset = textLines - .slice(0, targetLineIndex) - .reduce((sum, textLine) => sum + textLine.length + 1, 0) - - setEditableCaretOffset(/** @type {HTMLElement} */ (element), lineOffset + caretOffset) + if (useMultilineCaret) { + setEditableCaretOffset(/** @type {HTMLElement} */ (element), getCaretOffsetForSourceLine() + caretOffset) element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) return } @@ -938,13 +961,11 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca return } - if (isRangedLine && Number.isInteger(elementSourceLine)) { + if (useMultilineCaret) { + const lineOffset = getCaretOffsetForSourceLine() const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element)) const textLines = text.length ? text.split('\n') : [''] const targetLineIndex = Math.max(0, Math.min(lineIndex - elementSourceLine, textLines.length - 1)) - const lineOffset = textLines - .slice(0, targetLineIndex) - .reduce((sum, textLine) => sum + textLine.length + 1, 0) const lineText = textLines[targetLineIndex] ?? '' const nextOffset = cursorPosition === 'end' ? lineOffset + lineText.length @@ -972,8 +993,128 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) } +/** + * 선택 확장 대상 컨테이너를 반환한다. + * @param {{ navigationScope?: string, sourceLine?: number }} payload - 확장 요청 + * @returns {HTMLElement|null} 컨테이너 + */ +const resolveSelectionContainer = (payload) => { + if (!rendererRootRef.value) { + return null + } + + if (payload.navigationScope === 'parent' && typeof payload.sourceLine === 'number') { + const current = rendererRootRef.value.querySelector(`[data-source-line="${payload.sourceLine}"]`) + return current?.closest('[data-editable-scope]') || rendererRootRef.value + } + + return rendererRootRef.value +} + +/** + * Shift 선택을 인접 편집 블록으로 확장한다. + * @param {{ sourceLine: number, direction: number, column?: number, navigationScope?: string }} payload - 확장 요청 + * @returns {void} + */ +const onExtendLiveSelection = (payload) => { + if (!props.interactive || typeof payload?.sourceLine !== 'number' || !payload.direction) { + return + } + + const container = resolveSelectionContainer(payload) + + extendSelectionAcrossBlocks({ + container, + sourceLine: payload.sourceLine, + direction: payload.direction, + column: payload.column + }) +} + +/** + * 라이브 본문의 모든 편집 가능 텍스트를 선택한다. + * @returns {void} + */ +const selectAllLiveDocument = () => { + if (!props.interactive || !rendererRootRef.value) { + return + } + + const elements = getSelectableEditableElements(rendererRootRef.value) + selectAllEditableElements(elements) +} + +/** + * 교차 블록·전체 선택 삭제를 마크다운에 반영한다. + * @returns {boolean} 처리 여부 + */ +const deleteLiveSelection = () => { + if (!props.interactive || !rendererRootRef.value) { + return false + } + + const nextMarkdown = applyLiveSelectionDelete(props.content, rendererRootRef.value) + + if (nextMarkdown === null) { + return false + } + + emit('content-replace', { value: nextMarkdown }) + collapseLiveSelection() + return true +} + +/** + * 라이브 선택 삭제 단축키를 처리한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {void} + */ +const onRendererSelectionKeydown = (event) => { + if (!props.interactive || !isLiveSelectionDeleteKey(event)) { + return + } + + const isCut = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'x' + + if (isCut && import.meta.client) { + const selectedText = window.getSelection()?.toString() ?? '' + + if (selectedText) { + navigator.clipboard?.writeText(selectedText).catch(() => {}) + } + } + + if (!deleteLiveSelection()) { + return + } + + event.preventDefault() + event.stopPropagation() +} + +provide(LIVE_SELECTION_BRIDGE_KEY, { + extendSelection: (payload) => { + if (props.interactive) { + onExtendLiveSelection(payload) + } + }, + selectDocument: () => { + if (props.interactive) { + selectAllLiveDocument() + } + }, + deleteSelection: () => { + if (props.interactive) { + return deleteLiveSelection() + } + + return false + } +}) + defineExpose({ - focusEditableAtLine + focusEditableAtLine, + selectAllLiveDocument }) /** @@ -1509,6 +1650,7 @@ const applyParagraphShortcutSplit = (block, before, after) => { title: '' }), '', + '', ':::' ]) return true @@ -1685,6 +1827,10 @@ const removeGalleryImage = (block, imageIndex) => { * @returns {void} */ const onGalleryBlockKeydown = (event, block) => { + if (event.shiftKey && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + return + } + if (event.key === 'ArrowDown') { event.preventDefault() event.stopPropagation() @@ -1728,14 +1874,14 @@ const onPreviewCardKeydown = (event, block) => { return } - if (event.key === 'ArrowDown') { + if (!event.shiftKey && event.key === 'ArrowDown') { event.preventDefault() event.stopPropagation() moveFromPreviewCardBlock(block, 1) return } - if (event.key === 'ArrowUp') { + if (!event.shiftKey && event.key === 'ArrowUp') { event.preventDefault() event.stopPropagation() moveFromPreviewCardBlock(block, -1) @@ -2622,6 +2768,7 @@ onBeforeUnmount(() => { @mousedown.capture="emitLiveLineFocus" @focusin.capture="emitLiveLineFocus" @focusout.capture="emitLiveLineBlur" + @keydown.capture="onRendererSelectionKeydown" >