라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (v1.5.70)
Selection Bridge로 블록 간 선택·삭제를 보강하고, 콜아웃·인용 멀티라인 Enter·전체 선택 삭제·한글 IME 문제를 수정했다. Obsidian식 위첨자 문법과 RightSidebar 패딩·커스텀 아이콘 색상도 함께 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = '<!--sori:blank-paragraph-->'
|
||||
@@ -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"
|
||||
>
|
||||
<template v-for="block in blocks" :key="block.id">
|
||||
<div
|
||||
@@ -2667,6 +2814,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2684,6 +2833,8 @@ onBeforeUnmount(() => {
|
||||
enter-mode="multiline"
|
||||
plain-text
|
||||
arrow-exit-creates-line
|
||||
preserve-empty-line-on-full-delete
|
||||
empty-markdown-line="> "
|
||||
:source-line="getQuoteContentStartLine(block)"
|
||||
:source-line-count="getQuoteLineEntries(block).length"
|
||||
@input="onQuoteBlockCommit(block, $event)"
|
||||
@@ -2702,6 +2853,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2759,6 +2912,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2845,6 +3000,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-callout-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -2866,6 +3023,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-toggle-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
@@ -3092,6 +3251,8 @@ onBeforeUnmount(() => {
|
||||
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
|
||||
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
|
||||
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
|
||||
<sub v-else-if="segment.type === 'subscript'">{{ segment.text }}</sub>
|
||||
<sup v-else-if="segment.type === 'superscript'">{{ segment.text }}</sup>
|
||||
<code v-else-if="segment.type === 'code'" class="content-markdown-renderer__inline-code rounded bg-[#252525] px-1.5 py-0.5 text-[0.9em] text-white">{{ segment.text }}</code>
|
||||
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
|
||||
Reference in New Issue
Block a user