라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (v1.5.70)

Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-05 15:27:06 +09:00
parent 4c0875446b
commit 928b8446b4
13 changed files with 1458 additions and 117 deletions

View File

@@ -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"
/>
</template>