라이브 편집 선택·콜아웃·인용 안정화 및 오른쪽 사이드바 여백 보정 (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

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

View File

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

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>

View File

@@ -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>

View File

@@ -239,7 +239,7 @@ watch([postTocItems, () => route.fullPath], async () => {
<template>
<aside class="right-sidebar site-sidebar flex w-full flex-col overflow-hidden border-[var(--site-line)] max-lg:border-l-0 max-lg:border-t max-lg:px-4 max-lg:pb-10 lg:sticky lg:top-[57px] lg:z-10 lg:h-[calc(100vh-57px)] lg:max-h-[calc(100vh-57px)] lg:w-[287px] lg:self-start lg:border-l lg:border-t-0 lg:px-0">
<div class="right-sidebar__scroll site-sidebar-scroll min-h-0 flex-1">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0 max-lg:px-0">
<div class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-5 sm:pr-0 max-lg:px-0">
<div class="right-sidebar__profile flex items-center gap-3">
<div class="right-sidebar__logo grid h-12 w-12 place-items-center overflow-hidden rounded-2xl text-2xl font-bold text-[var(--site-invert-text)]">
<img
@@ -261,7 +261,7 @@ watch([postTocItems, () => route.fullPath], async () => {
</div>
</div>
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0">
<div v-if="followLinks.length" class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0">
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">
Follow
@@ -377,7 +377,7 @@ watch([postTocItems, () => route.fullPath], async () => {
</svg>
<span
v-else-if="item.icon === 'custom' && item.iconSvg"
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center"
class="right-sidebar__custom-social-icon inline-flex h-4 w-4 items-center justify-center fill-[var(--site-text)]"
aria-hidden="true"
v-html="item.iconSvg"
/>
@@ -440,7 +440,7 @@ watch([postTocItems, () => route.fullPath], async () => {
<div
v-else-if="recommendedSites.length"
class="right-sidebar__block site-sidebar-section py-5 pl-5 pr-0"
class="right-sidebar__block site-sidebar-section py-5 sm:pl-5 pr-0"
>
<div class="right-sidebar__row flex items-center justify-between">
<p class="right-sidebar__eyebrow text-xs font-semibold uppercase site-muted">

View File

@@ -1,5 +1,52 @@
# 업데이트 요약
## v1.5.70
- 라이브 모드 마지막 줄에서 `!!!`로 콜아웃을 만들 때 본문 줄을 안정적으로 확보하도록 수정했다.
- 콜아웃·인용 내부 전체 선택 후 Delete가 본문 삭제로 반영되도록 수정했다.
- 콜아웃·인용에서 줄바꿈 직후 한글 첫 글자가 자모로 분리되는 문제를 줄였다.
## v1.5.69
- 라이브 모드 인용·콜아웃에서 Enter 줄바꿈과 `Cmd+Shift+K` 줄 삭제가 다시 안정적으로 반영되도록 수정했다.
- 구조 변경 직후 이전 contenteditable DOM이 다시 저장되는 문제를 줄였다.
## v1.5.68
- 라이브 모드에서 Shift+위/아래로 인접 문단을 선택하는 동작을 다시 보강했다.
- `$H_2O$`, `$2^8$` 같은 Obsidian식 아래첨자·위첨자 표시를 추가했다.
## v1.5.67
- 라이브 모드에서 Shift+방향키로 다음 문단까지 범위 선택이 더 안정적으로 동작하도록 수정했다.
- 라이브 모드에서 여러 블록을 선택한 뒤 삭제·잘라내기가 소스 모드처럼 본문에 반영되도록 수정했다.
## v1.5.66
- 라이브 모드에서 Shift+방향키로 여러 문단·블록을 한 번에 범위 선택할 수 있게 했다.
- 라이브 모드 `Cmd/Ctrl+A`를 현재 블록 전체 선택과 본문 전체 선택으로 나눴다.
## v1.5.65
- 라이브 모드 인용·콜아웃 같은 본문 블록에서 한글 마지막 글자 입력 후 Enter 한 번으로 줄바꿈되도록 보강했다.
## v1.5.64
- 라이브 모드 편집 영역에서 Shift 범위 선택과 전체 선택이 다시 동작하도록 수정했다.
## v1.5.63
- 라이브 모드에서 한글 입력 중 Enter를 눌렀을 때 글자 확정 뒤 줄바꿈·블록 분리가 바로 이어지도록 수정했다.
- 문단, 제목, 목록, 인용, 콜아웃, 코드, 토글 편집에서 같은 Enter 동작을 쓰도록 보강했다.
## v1.5.62
- 라이브 모드에서 문단을 합친 뒤 Enter로 다시 나눌 때 아래 줄 내용이 복제되던 문제를 수정했다.
## v1.5.61
- 콜아웃 본문 첫 줄이 비어 있는 상태에서 소스·라이브 모드를 오갈 때 본문이 사라지던 문제를 수정했다.
## v1.5.55
- 소스 모드와 라이브 모드에서 `Cmd+Shift+K` 줄 삭제 단축키가 다시 동작하도록 보강했다.

View File

@@ -1,5 +1,45 @@
# 의사결정 이력
## 2026-06-05 v1.5.70 — 라이브 멀티라인 Enter는 DOM 조각 삽입 대신 텍스트 값을 갱신한다
콜아웃·인용 본문은 `white-space: pre-wrap`인 plain text contenteditable로 관리한다. Range에 직접 텍스트 노드를 삽입하면 줄바꿈 직후 브라우저 IME 조합 위치가 불안정해져 한글 첫 글자가 자모로 분리될 수 있다. 멀티라인 Enter는 전체 텍스트 값을 기준으로 선택 범위를 `\n`으로 교체하고 커서를 텍스트 오프셋으로 다시 배치한다. 또한 Selection Bridge의 Range 교차 판정을 바로잡고, 콜아웃·인용 전체 선택 삭제는 블록 자체가 아니라 빈 본문 줄을 남기도록 한다.
## 2026-06-05 v1.5.69 — 멀티라인 라이브 편집은 끝 줄바꿈을 보존한다
인용·콜아웃 본문은 하나의 contenteditable 안에서 여러 원본 줄을 대표한다. 이 값 읽기에서 끝 줄바꿈을 잘라내면 마지막 줄에서 Enter를 눌러도 모델에는 새 줄이 남지 않고, 삭제·병합 같은 구조 변경 뒤에는 포커스 중인 오래된 DOM이 blur 때 다시 저장될 수 있다. 멀티라인 plain text 편집은 끝 줄바꿈을 보존해 읽고, 줄 삭제·병합 전에는 stale blur 커밋을 잠시 차단해 부모 모델 갱신이 우선되게 한다.
## 2026-06-05 v1.5.68 — Shift+위/아래 선택은 커서 위치 기준으로 확장한다
라이브 편집은 블록별 contenteditable 구조라 단일 줄 문단 안에서 브라우저 기본 Shift+위/아래가 다음 블록으로 자연스럽게 이어지지 않는다. 블록 끝에서만 확장하는 방식은 textarea의 줄 선택 경험과 달라 실제 글쓰기에서 실패처럼 느껴지므로, 단일 줄 블록에서는 커서 위치와 상관없이 Selection Bridge가 인접 블록의 같은 열로 선택을 확장하도록 바꾼다. 연속 확장 시에는 `sourceLine`이 아니라 Selection의 focus 노드가 속한 편집 요소를 현재 블록으로 사용한다.
## 2026-06-05 v1.5.67 — 라이브 교차 선택 삭제는 DOM이 아니라 마크다운을 갱신한다
교차 블록 선택은 DOM에는 보이지만 각 contenteditable의 키 처리만으로는 삭제가 반영되지 않는다. `lib/markdown-live-selection.js`가 선택 범위를 원본 줄·편집 본문 오프셋으로 변환하고, `ContentMarkdownRenderer``content-replace`로 상위 에디터 본문을 한 번에 갱신한다. Shift+방향키 확장은 줄 범위 탐색과 선택 포커스 경계 판별을 보강하고, Shift 조합 시 블록 이동 단축키와 충돌하지 않게 분리한다.
## 2026-06-05 v1.5.66 — 라이브 선택은 블록 편집 구조를 유지한 채 브리지로 이어준다
라이브 모드를 단일 contenteditable 문서로 바꾸면 Enter·IME·블록 옵션·카드형 블록 편집과 충돌한다. 대신 각 블록 편집기는 유지하고 `ContentMarkdownRenderer`가 Selection API로 Shift 범위 확장과 단계적 전체 선택을 중개한다.
## 2026-06-05 v1.5.65 — IME 확정 Enter의 keyup 경로도 처리한다
일부 브라우저·한글 IME 조합에서는 마지막 글자 확정에 Enter를 쓰면 `keydown`이 contenteditable까지 전달되지 않고 `compositionend``keyup`만 남는다. 이 경우 기존 pending 방식만으로는 줄바꿈 동작을 예약할 수 없어 Enter를 한 번 더 눌러야 했다. 조합 종료 직후 짧은 시간 안에 들어온 Enter `keyup`도 같은 물리 키 입력으로 보고, 현재 Enter 모드에 맞는 블록 동작을 한 번만 실행한다.
## 2026-06-05 v1.5.64 — 텍스트 선택은 블록 이동보다 우선한다
라이브 편집은 블록 단위 contenteditable이 여러 개이므로 문서 전체를 한 번에 드래그 선택할 수는 없다. 다만 각 편집 영역 안에서는 Shift+방향키·전체 선택이 반드시 동작해야 한다. 방향키 블록 이동 단축키가 Shift 조합까지 가로채면 본문 편집이 망가지므로, 선택 제스처일 때는 커스텀 키 처리를 건너뛴다.
## 2026-06-05 v1.5.63 — 라이브 편집 IME Enter 처리는 공통 컴포넌트에서 통일한다
한국어 입력 중 Enter는 브라우저와 IME에 따라 글자 조합 확정 이벤트와 키 입력 이벤트 순서가 다르게 들어온다. 이를 블록별 컴포넌트에서 따로 처리하면 문단·목록·토글 제목·멀티라인 본문 사이의 동작이 다시 달라질 수 있다. 라이브 편집의 모든 텍스트 블록이 `ContentMarkdownEditableInline`을 거치므로, 조합 중 Enter를 공통 pending 동작으로 저장하고 `compositionend` 직후 각 Enter 모드의 실제 동작을 실행하도록 통일한다.
## 2026-05-26 v1.5.62 — Enter 분리 직후 포커스 중 DOM을 modelValue에 맞춘다
문단 Enter 분리는 마크다운을 먼저 갱신하고 Vue가 `modelValue`를 줄인 뒤에도, 포커스 중인 contenteditable은 병합 직전의 긴 문자열을 DOM에 남긴다. 이 상태에서 blur가 나가면 통째 줄이 다시 커밋되어 아래 줄이 복제된 것처럼 보인다. 분리·병합 직후에는 `modelValue`와 DOM을 강제 동기화하고, 구조 변경 직후의 stale blur 커밋은 차단한다.
## 2026-05-26 v1.5.61 — 멀티라인 포커스 시 빈 줄은 편집 영역 전체를 비우지 않는다
콜아웃·코드·토글 본문은 하나의 contenteditable이 `data-source-line`~`data-source-line-end`로 여러 원본 줄을 대표한다. 소스·라이브 전환 후 `focusEditableAtLine`은 대상 원본 줄이 비어 있으면 단일 줄 편집기처럼 DOM을 비우는데, 범위의 첫 줄이 비어 있을 때는 멀티라인 편집기 전체가 지워져 blur 커밋으로 마크다운까지 유실된다. 빈 줄 초기화는 단일 줄 편집기에만 적용하고, 줄 범위가 2줄 이상인 편집기는 해당 원본 줄 위치로 커서만 둔다.
## 2026-06-04 v1.5.54 — 콜아웃 제목 옵션과 기본 아이콘 미사용
콜아웃은 본문 시작 전 시각 신호를 독립적으로 보여주는 편이 여러 줄 본문에서 정렬이 안정적이다. 따라서 라이브·공개 렌더링을 아이콘·제목 헤더와 본문 영역으로 분리하고, 제목은 기존 콜아웃 선언부에 `title` 옵션으로 저장한다. 새 콜아웃은 본문 작성 부담을 줄이기 위해 기본 아이콘 표시를 끄고, 필요할 때 오른쪽 블록 설정 패널에서 켜도록 한다.

View File

@@ -112,8 +112,9 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운 렌더링과 plain text 멀티라인 본문 보존, 멀티라인 Enter 텍스트 줄바꿈 삽입, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, Obsidian식 위첨자·아래첨자 인라인 렌더링, 확장 블록 파싱, `:::` fenced 블록 원본 범위 보정, 닫히지 않은 코드 펜스 하위 콘텐츠 보호, 인용 막대 색상 옵션(`> [!bg=...]`), 라이브 문단 `>` 즉시 인용 변환과 ` ``` `·`!!!` Enter 코드 블록·콜아웃 단축 생성, 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용·콜아웃 멀티라인 편집 줄 범위 포커스와 위/아래 방향키 외부 문단 이탈, 인용 Backspace 문단 복귀, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownEditableInline.vue | 라이브 모드 공통 인라인 편집 영역, 일반 인라인 마크다운·Obsidian식 첨자 렌더링과 plain text 멀티라인 본문·끝 줄바꿈 보존, 한글 IME 조합 확정 Enter의 블록별 동작 연결, Shift 위/아래 인접 블록 선택 확장·단계적 `Cmd/Ctrl+A` 처리, 멀티라인 Enter 텍스트 값 치환, 첫 줄 빈 줄 포함 줄바꿈 유지 |
| lib/markdown-live-selection.js | 라이브 모드 Selection Bridge, 인접 contenteditable 범위 확장·블록/문서 전체 선택·교차 선택 삭제 마크다운 반영, 콜아웃·인용 전체 선택 삭제 시 빈 본문 줄 보존, Selection focus 기준 연속 확장 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
| components/content/ProseList.vue | 목록 |

View File

@@ -628,7 +628,8 @@ components/content/
- `ProseImage`는 URL이 비어 있거나 로드에 실패해도 최소 높이 placeholder와 「이미지를 불러올 수 없음」 안내를 표시해 라이브 모드에서 블록 선택·편집이 가능하다.
- 인용(`>`) 블록은 첫 인용 줄에 `> [!bg=yellow]` 또는 `> {bg=yellow}` 옵션 줄을 두면 해당 줄은 숨기고 블록 배경을 바꾼다. 지원 배경 프리셋은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이며, 옵션이 없으면 회색 기본 인용 스타일을 쓴다.
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록·코드 블록·콜아웃·토글을 렌더 스타일 그대로 contenteditable로 수정한다. blur·문단 이동(방향키) 시 편집 영역의 `<strong>`·`<em>` 등을 `**`·`*` 마크다운으로 다시 직렬화해 저장한다. **Enter**·**Shift+Enter** 모두 다음 문단(블록) 분리. 문단 안 `/`로 슬래시 명령 메뉴(`/image`+Enter 이미지 삽입 등). **소스(작성) 모드** textarea에서도 동일한 `/` 슬래시 메뉴를 사용하며, 상단 마크다운 툴바는 두지 않는다. 슬래시 기본 제목은 **h2·h3·h4**만 표시하며, 본문 **h1**은 `/h1` 검색 시에만 삽입한다(게시물 **제목 필드**가 페이지의 유일한 h1). `Cmd+Shift+K`는 소스 모드와 라이브 모드에서 현재 줄을 삭제하며, 소스 모드에서 여러 줄이 선택되어 있으면 선택 범위가 걸친 줄을 함께 삭제한다. 코드·콜아웃·토글 블록 내부에서는 커서가 있는 본문 줄을 삭제하고, 남은 본문 줄이 1개뿐이면 fenced 블록 전체를 삭제한다. 콜아웃 옵션은 첫 줄 `:::callout emoji=none bg=blue title="주의사항"`처럼 `emoji`·`bg`(gray|blue|green|yellow|red|purple)·`title`로 지정하며, 라이브 모드에서는 블록에 포커스가 들어오면 오른쪽 설정 패널에서 수정한다. 코드 블록은 ` ```언어`·`nolinenos`(줄 번호 숨김)를 지원한다. 라이브·공개 모두 `ProseCodeBlock`(`#15171a`, `px-4 py-3`, `text-sm leading-6`)으로 동일하게 표시한다. 라이브 모드 호버·포커스 시 Language 입력·줄번호 토글이 보인다. 공개 화면에는 언어 라벨 옆 **복사** 버튼으로 본문을 클립보드에 넣는다. 본문 하단 클릭으로 새 문단을 추가한다.
- 라이브 모드 인용·콜아웃 내부 Enter는 한글 IME 조합 확정 뒤에도 한 번만 줄을 추가한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 줄바꿈을 삽입하며, 첫 줄 빈 줄을 포함한 선행·후행 줄바꿈을 보존해 소스·라이브 모드 전환 시 본문이 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들 수 있지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
- 라이브 모드 공통 편집기는 한글 IME 조합 확정 Enter를 문단·빈 줄의 분리, 제목·목록의 아래 줄 삽입, 토글 제목의 본문 이동, 인용·콜아웃·코드·토글 본문의 텍스트 줄바꿈에 동일하게 연결한다. IME가 확정 Enter의 `keydown`을 전달하지 않고 `keyup`만 남기는 경우에도 조합 종료 직후 Enter로 보고 같은 동작을 실행한다. Shift+방향키 범위 선택은 `ContentMarkdownRenderer` Selection Bridge가 인접 편집 블록까지 확장한다. 단일 줄 블록의 Shift+위/아래는 커서 위치와 상관없이 현재 열 기준으로 이전·다음 편집 블록까지 확장하고, 멀티라인 편집 영역은 첫 줄·마지막 줄 경계에서 `data-source-line`~`data-source-line-end` 줄 범위로 탐색한다. `Cmd/Ctrl+A` 1회는 현재 블록 전체, 짧은 시간 안 2회는 라이브 본문 전체를 선택한다. 교차 블록·전체 선택 상태에서 **Backspace**·**Delete**·**Cmd/Ctrl+X**는 선택 범위를 마크다운 줄로 변환해 `content-replace`로 본문을 갱신한다. 콜아웃·인용 내부 전체 선택 삭제는 블록을 제거하지 않고 빈 본문 줄을 남긴다. 블록 이동 단축키는 Shift 조합일 때 실행하지 않으며, 선택 제스처가 우선한다. 콜아웃 본문은 하나의 멀티라인 편집 영역으로 유지해 `Shift+방향키` 선택이 내부 여러 줄을 가로지를 수 있게 한다. 라이브 멀티라인 블록은 브라우저 기본 DOM 줄 생성 대신 텍스트 값 치환 방식으로 줄바꿈을 삽입하며, contenteditable 값을 읽을 때 끝 줄바꿈을 보존해 첫 줄 빈 줄과 후행 빈 줄이 소스·라이브 모드 전환 또는 저장 중 유실되지 않게 한다. 인용 마지막 줄에서 아래 방향키를 누르면 외부 빈 문단을 만들 수 있지만, 콜아웃 아래 방향키는 본문 줄을 새로 만들지 않는다.
- 인라인 마크다운은 Obsidian식 `$...$` 첨자 토큰을 지원한다. `$H_2O$``H`+아래첨자 `2`+`O`, `$2^8$``2`+위첨자 `8`, `$_B^AR$`는 아래첨자 `B`와 위첨자 `AR`로 렌더링한다. 첨자 본문에 공백·기호가 필요하면 `$_{...}$`, `$^{...}$` 형식도 허용한다.
- 라이브 모드 `:::` fenced 블록의 원본 범위는 여는 줄부터 닫는 `:::` 줄까지만 포함한다. 연속된 콜아웃·토글·갤러리 등은 앞 블록 편집 시 다음 블록의 선언 줄을 교체 범위에 포함하지 않는다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.

View File

@@ -1,5 +1,53 @@
# 업데이트 이력
## v1.5.70
- 게시물 글쓰기: 라이브 모드 마지막 줄 `!!!` Enter 콜아웃 단축 생성 시 본문 빈 줄을 두 줄로 만들도록 수정.
- 게시물 글쓰기: 라이브 콜아웃·인용 내부 `Cmd/Ctrl+A` 후 Delete가 선택 범위를 실제 본문 삭제로 반영하도록 Selection Bridge Range 교차 판정 수정.
- 게시물 글쓰기: 라이브 인용·콜아웃 줄바꿈 직후 한글 첫 글자 자모 분리 방지를 위해 plain text 멀티라인 Enter 삽입 경로 정리.
## v1.5.69
- 게시물 글쓰기: 라이브 인용·콜아웃 멀티라인 본문 Enter 줄바꿈이 끝 줄에서 잘리지 않도록 contenteditable 읽기 옵션 보강.
- 게시물 글쓰기: 라이브 인용·콜아웃에서 `Cmd+Shift+K` 줄 삭제·Backspace 병합 직후 오래된 DOM이 다시 커밋되지 않도록 구조 변경 stale blur 차단 추가.
## v1.5.68
- 게시물 글쓰기: 라이브 모드 Shift+위/아래 선택 확장을 커서 위치 기준으로 보강. 단일 줄 블록에서는 커서가 중간에 있어도 인접 블록 같은 열까지 선택 확장.
- 게시물 본문: Obsidian식 `$H_2O$`, `$2^8$`, `$_B^AR$` 위첨자·아래첨자 인라인 렌더링 추가.
## v1.5.67
- 게시물 글쓰기: 라이브 모드 Shift+방향키 블록 경계 선택 확장 보강. 줄 범위 기반 블록 탐색·선택 포커스 경계 판별·Shift+방향키 블록 이동 충돌 제거.
- 게시물 글쓰기: 라이브 모드 교차 블록·전체 선택 후 Backspace/Delete/Cmd+X 삭제를 마크다운 본문에 반영하도록 Selection Bridge 삭제 경로 추가.
## v1.5.66
- 게시물 글쓰기: 라이브 모드 Selection Bridge 추가. Shift+방향키로 서로 다른 문단·블록 범위 선택 확장 지원.
- 게시물 글쓰기: 라이브 모드 `Cmd/Ctrl+A` 1회는 현재 블록 전체, 2회는 본문 전체 선택으로 단계화.
- 게시물 글쓰기: 라이브 미리보기 루트 포커스에서 `Cmd/Ctrl+A`로 본문 전체 선택 지원.
## v1.5.65
- 게시물 글쓰기: 라이브 인용·콜아웃·코드·토글 본문에서 한글 조합 확정 Enter의 `keydown`이 전달되지 않고 `keyup`만 남는 경우에도 줄바꿈이 즉시 실행되도록 수정.
## v1.5.64
- 게시물 글쓰기: 라이브 모드에서 Shift+방향키 범위 선택과 `Cmd/Ctrl+A` 전체 선택이 블록 이동 단축키에 가로막히던 문제 수정.
## v1.5.63
- 게시물 글쓰기: 라이브 모드 공통 편집기의 한글 IME 조합 중 Enter 처리를 문단·빈 줄·제목·목록·토글 제목·인용·콜아웃·코드·토글 본문 전체에 동일하게 적용.
- 게시물 글쓰기: 한글 글자 조합 확정 Enter 직후에도 해당 블록의 줄바꿈·블록 분리·다음 필드 이동 동작이 한 번에 이어지도록 수정.
## v1.5.62
- 게시물 글쓰기: 라이브 모드에서 줄 병합(Backspace) 후 Enter로 다시 나눌 때 이전 블록 DOM이 남아 본문이 중복·통째 줄로 커밋되던 문제 수정.
## v1.5.61
- 게시물 글쓰기: 콜아웃 본문 첫 줄이 빈 줄일 때 소스·라이브 전환 후 `focusEditableAtLine`이 멀티라인 편집 영역 전체를 비워 본문이 사라지던 문제 수정.
## v1.5.60
- 게시물 글쓰기: 라이브 콜아웃·코드·인용 등 멀티라인 편집 영역 Enter를 브라우저 기본 DOM 줄 생성 대신 텍스트 줄바꿈 삽입으로 처리하도록 수정.

View File

@@ -1,5 +1,5 @@
/** @type {RegExp} 인라인 마크다운 패턴 */
const INLINE_MARKDOWN_RE = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
const INLINE_MARKDOWN_RE = /(\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
/**
* HTML 특수문자 이스케이프
@@ -12,6 +12,120 @@ export const escapeHtml = (value) => String(value || '')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
/**
* 위첨자·아래첨자 토큰 본문을 읽는다.
* @param {string} source - 수식 본문
* @param {number} startIndex - 토큰 시작 위치
* @returns {{ text: string, nextIndex: number }}
*/
const readScriptToken = (source, startIndex) => {
const first = source[startIndex]
if (!first) {
return { text: '', nextIndex: startIndex }
}
if (first === '{') {
const closeIndex = source.indexOf('}', startIndex + 1)
if (closeIndex > startIndex) {
return {
text: source.slice(startIndex + 1, closeIndex),
nextIndex: closeIndex + 1
}
}
}
if (/\d/.test(first)) {
let index = startIndex
while (index < source.length && /\d/.test(source[index])) {
index += 1
}
return {
text: source.slice(startIndex, index),
nextIndex: index
}
}
if (/[A-Za-z가-힣]/.test(first)) {
let index = startIndex
while (index < source.length && source[index] !== '_' && source[index] !== '^') {
index += 1
}
return {
text: source.slice(startIndex, index),
nextIndex: index
}
}
return {
text: first,
nextIndex: startIndex + 1
}
}
/**
* Obsidian식 `$H_2O$` 인라인 수식을 세그먼트로 변환한다.
* @param {string} value - 수식 본문
* @returns {Array<{ type: string, text: string }>} 인라인 세그먼트
*/
export const parseObsidianMathSegments = (value) => {
const source = String(value || '')
const segments = []
let textBuffer = ''
let index = 0
const flushText = () => {
if (!textBuffer) {
return
}
segments.push({ type: 'text', text: textBuffer })
textBuffer = ''
}
while (index < source.length) {
const marker = source[index]
if ((marker === '_' || marker === '^') && index < source.length - 1) {
const token = readScriptToken(source, index + 1)
if (token.text) {
flushText()
segments.push({
type: marker === '_' ? 'subscript' : 'superscript',
text: token.text
})
index = token.nextIndex
continue
}
}
textBuffer += marker
index += 1
}
flushText()
return segments
}
/**
* 위첨자·아래첨자를 Obsidian식 `$...$` 토큰으로 직렬화한다.
* @param {'_'|'^'} marker - 첨자 기호
* @param {string} value - 첨자 본문
* @returns {string}
*/
const formatScriptMarkdown = (marker, value) => {
const text = String(value || '')
const body = /^[A-Za-z0-9가-힣]+$/.test(text) ? text : `{${text}}`
return `$${marker}${body}$`
}
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
@@ -32,27 +146,29 @@ export const parseInlineSegments = (value) => {
})
}
if (match[2] && match[3]) {
if (match[2]) {
segments.push(...parseObsidianMathSegments(match[2]))
} else if (match[3] && match[4]) {
segments.push({
type: 'link',
text: match[2],
href: match[3]
})
} else if (match[4]) {
segments.push({
type: 'strong',
text: match[4]
text: match[3],
href: match[4]
})
} else if (match[5]) {
segments.push({
type: 'code',
type: 'strong',
text: match[5]
})
} else if (match[6]) {
segments.push({
type: 'em',
type: 'code',
text: match[6]
})
} else if (match[7]) {
segments.push({
type: 'em',
text: match[7]
})
}
lastIndex = INLINE_MARKDOWN_RE.lastIndex
@@ -83,6 +199,14 @@ export const segmentsToInlineHtml = (segments) => segments.map((segment) => {
return `<em>${escapeHtml(segment.text)}</em>`
}
if (segment.type === 'subscript') {
return `<sub>${escapeHtml(segment.text)}</sub>`
}
if (segment.type === 'superscript') {
return `<sup>${escapeHtml(segment.text)}</sup>`
}
if (segment.type === 'code') {
return `<code>${escapeHtml(segment.text)}</code>`
}
@@ -137,6 +261,14 @@ export const convertHtmlInlineNodeToMarkdown = (node) => {
return `*${childText}*`
}
if (tagName === 'sub') {
return formatScriptMarkdown('_', childText)
}
if (tagName === 'sup') {
return formatScriptMarkdown('^', childText)
}
if (tagName === 'code') {
return `\`${childText}\``
}
@@ -283,9 +415,10 @@ const readEditableChildNodeToMarkdown = (node) => {
/**
* contenteditable 루트에서 텍스트를 읽는다.
* @param {HTMLElement} root - contenteditable 루트
* @param {{ trimEnd?: boolean }} [options] - 읽기 옵션
* @returns {string} 마크다운 인라인 텍스트
*/
export const readEditableTextFromElement = (root) => {
export const readEditableTextFromElement = (root, options = {}) => {
if (!root) {
return ''
}
@@ -302,7 +435,8 @@ export const readEditableTextFromElement = (root) => {
parts.push(readEditableChildNodeToMarkdown(node))
}
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
const value = parts.join('').replace(/\u00a0/g, ' ')
return options.trimEnd === false ? value : value.trimEnd()
}
/**
@@ -418,6 +552,49 @@ export const setEditableCaretOffset = (root, targetOffset) => {
}
}
/**
* contenteditable 루트의 텍스트 오프셋에 해당하는 DOM 위치를 반환한다.
* @param {HTMLElement} root - contenteditable 루트
* @param {number} targetOffset - 텍스트 오프셋
* @returns {{ node: Node, offset: number }|null} DOM 위치
*/
export const getEditableDomPointAtOffset = (root, targetOffset) => {
if (!root || !import.meta.client) {
return null
}
const safeOffset = Math.max(0, targetOffset)
let walked = 0
for (const unit of iterateEditableTextUnits(root)) {
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
if (walked + unit.length >= safeOffset) {
return {
node: unit.node,
offset: Math.min(safeOffset - walked, unit.length)
}
}
walked += unit.length
continue
}
if (walked + unit.length >= safeOffset) {
return {
node: root,
offset: root.childNodes.length
}
}
walked += unit.length
}
return {
node: root,
offset: root.childNodes.length
}
}
/**
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
* @param {string} html - innerHTML

View File

@@ -0,0 +1,489 @@
import {
getEditableCaretOffset,
getEditableDomPointAtOffset,
readEditableTextFromElement
} from './markdown-inline.js'
/** @type {import('vue').InjectionKey<{ extendSelection: Function, selectDocument: Function, deleteSelection: Function }>} */
export const LIVE_SELECTION_BRIDGE_KEY = Symbol('liveSelectionBridge')
/**
* 컨테이너 안의 선택 가능한 contenteditable 요소 목록을 반환한다.
* @param {HTMLElement|null} container - 탐색 루트
* @returns {HTMLElement[]} 편집 요소 목록
*/
export const getSelectableEditableElements = (container) => {
if (!container || !import.meta.client) {
return []
}
return [...container.querySelectorAll('[data-source-line][contenteditable="true"]')]
.filter((element) => element.getAttribute('contenteditable') === 'true')
.sort((left, right) => Number(left.getAttribute('data-source-line')) - Number(right.getAttribute('data-source-line')))
}
/**
* 노드가 속한 편집 요소를 반환한다.
* @param {Node|null} node - 기준 노드
* @param {HTMLElement[]} elements - 편집 요소 목록
* @returns {HTMLElement|null}
*/
export const getEditableElementFromNode = (node, elements) => {
if (!node) {
return null
}
return elements.find((element) => element.contains(node)) ?? null
}
/**
* 원본 줄 번호에 해당하는 편집 요소 인덱스를 찾는다.
* @param {HTMLElement[]} elements - 편집 요소 목록
* @param {number} sourceLine - 원본 줄 번호(0-based)
* @returns {number} 인덱스(없으면 -1)
*/
export const findEditableIndexBySourceLine = (elements, sourceLine) => {
return elements.findIndex((element) => {
const start = Number(element.getAttribute('data-source-line'))
const end = Number(element.getAttribute('data-source-line-end'))
if (!Number.isInteger(start)) {
return false
}
if (Number.isInteger(end) && end > start) {
return sourceLine >= start && sourceLine <= end
}
return start === sourceLine
})
}
/**
* 편집 요소의 원본 줄 범위를 반환한다.
* @param {HTMLElement} element - contenteditable 요소
* @returns {{ startLine: number, endLine: number }}
*/
export const getEditableSourceLineRange = (element) => {
const startLine = Number(element.getAttribute('data-source-line'))
const endLine = Number(element.getAttribute('data-source-line-end'))
if (!Number.isInteger(startLine)) {
return { startLine: -1, endLine: -1 }
}
return {
startLine,
endLine: Number.isInteger(endLine) ? endLine : startLine
}
}
/**
* 선택 범위가 편집 요소와 겹치는지 확인한다.
* @param {Range} range - 선택 범위
* @param {HTMLElement} element - contenteditable 요소
* @returns {boolean}
*/
export const rangeIntersectsElement = (range, element) => {
const elementRange = document.createRange()
elementRange.selectNodeContents(element)
return range.compareBoundaryPoints(Range.START_TO_END, elementRange) < 0
&& range.compareBoundaryPoints(Range.END_TO_START, elementRange) > 0
}
/**
* 편집 요소 안에서 선택 경계의 텍스트 오프셋을 계산한다.
* @param {HTMLElement} element - contenteditable 요소
* @param {Range} range - 선택 범위
* @param {'start'|'end'} edge - 경계
* @returns {number}
*/
export const getSelectionOffsetInElement = (element, range, edge) => {
const partial = document.createRange()
partial.selectNodeContents(element)
if (edge === 'start') {
partial.setEnd(range.startContainer, range.startOffset)
} else {
partial.setStart(range.endContainer, range.endOffset)
}
return getEditableCaretOffset(element, partial)
}
/**
* 마크다운 줄에서 편집 본문 앞 접두사 길이를 반환한다.
* @param {string} markdownLine - 마크다운 한 줄
* @param {string} bodyText - 편집 본문
* @returns {number}
*/
export const getMarkdownLineBodyPrefixLength = (markdownLine, bodyText) => {
const line = String(markdownLine ?? '')
const body = String(bodyText ?? '')
if (line === body) {
return 0
}
const prefixPatterns = [
/^(#{1,6}\s+)/,
/^(>\s*)/,
/^([-*+]\s+)/,
/^(\d+\.\s+)/
]
for (const pattern of prefixPatterns) {
const match = line.match(pattern)
if (match && line.slice(match[0].length) === body) {
return match[0].length
}
}
if (line.endsWith(body)) {
return line.length - body.length
}
return 0
}
/**
* 마크다운 한 줄에 편집 본문 치환 결과를 반영한다.
* @param {string} markdownLine - 마크다운 한 줄
* @param {string} bodyText - 편집 본문
* @param {string} nextBody - 치환 후 본문
* @returns {string}
*/
export const rebuildMarkdownLineBody = (markdownLine, bodyText, nextBody) => {
const prefixLength = getMarkdownLineBodyPrefixLength(markdownLine, bodyText)
return `${String(markdownLine ?? '').slice(0, prefixLength)}${nextBody}`
}
/**
* 마크다운 한 줄에서 본문 일부를 삭제한다.
* @param {string} markdownLine - 마크다운 한 줄
* @param {string} bodyText - 편집 본문
* @param {number} startOffset - 시작 오프셋
* @param {number} endOffset - 끝 오프셋
* @returns {string}
*/
export const applyBodyEditToMarkdownLine = (markdownLine, bodyText, startOffset, endOffset) => {
const prefixLength = getMarkdownLineBodyPrefixLength(markdownLine, bodyText)
const line = String(markdownLine ?? '')
const absoluteStart = prefixLength + startOffset
const absoluteEnd = prefixLength + endOffset
return `${line.slice(0, absoluteStart)}${line.slice(absoluteEnd)}`
}
/**
* 인접 블록 선택 확장 대상 오프셋을 계산한다.
* @param {string} text - 대상 편집 요소 텍스트
* @param {number} direction - 이동 방향
* @param {number} column - 유지할 열
* @returns {number}
*/
export const getTargetSelectionOffset = (text, direction, column) => {
const source = String(text ?? '')
const lines = source.length ? source.split('\n') : ['']
const safeColumn = Math.max(0, column)
if (direction > 0) {
const lineText = lines[0] ?? ''
return Math.min(safeColumn, lineText.length)
}
const lineText = lines[lines.length - 1] ?? ''
const previousLength = lines.slice(0, -1).reduce((sum, line) => sum + line.length + 1, 0)
return previousLength + Math.min(safeColumn, lineText.length)
}
/**
* 편집 요소 본문 전체를 선택한다.
* @param {HTMLElement} element - contenteditable 요소
* @returns {void}
*/
export const selectEditableElementContents = (element) => {
if (!element || !import.meta.client) {
return
}
const selection = window.getSelection()
if (!selection) {
return
}
const range = document.createRange()
range.selectNodeContents(element)
selection.removeAllRanges()
selection.addRange(range)
}
/**
* 편집 요소 목록 전체를 하나의 선택 범위로 만든다.
* @param {HTMLElement[]} elements - 편집 요소 목록
* @returns {void}
*/
export const selectAllEditableElements = (elements) => {
if (!import.meta.client || !elements.length) {
return
}
const selection = window.getSelection()
if (!selection) {
return
}
const first = elements[0]
const last = elements[elements.length - 1]
const startPoint = getEditableDomPointAtOffset(first, 0)
const endText = readEditableTextFromElement(last)
const endPoint = getEditableDomPointAtOffset(last, endText.length)
if (!startPoint || !endPoint) {
return
}
selection.setBaseAndExtent(
startPoint.node,
startPoint.offset,
endPoint.node,
endPoint.offset
)
}
/**
* 인접 편집 블록으로 선택 범위를 확장한다.
* @param {{ container: HTMLElement|null, sourceLine: number, direction: number, column?: number }} options - 확장 옵션
* @returns {boolean} 확장 여부
*/
export const extendSelectionAcrossBlocks = ({
container,
sourceLine,
direction,
column = 0
}) => {
if (!import.meta.client || !container || !direction) {
return false
}
const elements = getSelectableEditableElements(container)
const currentIndex = findEditableIndexBySourceLine(elements, sourceLine)
if (currentIndex < 0) {
return false
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return false
}
const focusedElement = getEditableElementFromNode(selection.focusNode, elements)
const focusedIndex = focusedElement ? elements.indexOf(focusedElement) : -1
const resolvedCurrentIndex = focusedIndex >= 0 ? focusedIndex : currentIndex
const targetIndex = resolvedCurrentIndex + direction
if (targetIndex < 0 || targetIndex >= elements.length) {
return false
}
const target = elements[targetIndex]
const targetText = readEditableTextFromElement(target)
const anchorPoint = {
node: selection.anchorNode,
offset: selection.anchorOffset
}
const focusPoint = getEditableDomPointAtOffset(
target,
getTargetSelectionOffset(targetText, direction, column)
)
if (!anchorPoint?.node || !focusPoint) {
return false
}
selection.setBaseAndExtent(
anchorPoint.node,
anchorPoint.offset,
focusPoint.node,
focusPoint.offset
)
return true
}
/**
* 편집 요소가 현재 전체 선택 상태인지 확인한다.
* @param {HTMLElement} element - contenteditable 요소
* @returns {boolean}
*/
export const isEditableElementFullySelected = (element) => {
if (!element || !import.meta.client) {
return false
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return false
}
const range = selection.getRangeAt(0)
const fullText = readEditableTextFromElement(element)
if (!fullText.length) {
return false
}
const startOffset = getSelectionOffsetInElement(element, range, 'start')
const endOffset = getSelectionOffsetInElement(element, range, 'end')
return startOffset === 0 && endOffset >= fullText.length
}
/**
* 선택 삭제·잘라내기 단축키인지 확인한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {boolean}
*/
export const isLiveSelectionDeleteKey = (event) => {
if (event.key === 'Backspace' || event.key === 'Delete') {
return true
}
return (event.metaKey || event.ctrlKey)
&& !event.shiftKey
&& !event.altKey
&& event.key.toLowerCase() === 'x'
}
/**
* 라이브 선택을 마크다운에 반영해 삭제한다.
* @param {string} markdown - 현재 마크다운
* @param {HTMLElement|null} container - 렌더러 루트
* @returns {string|null} 갱신된 마크다운(처리하지 않으면 null)
*/
export const applyLiveSelectionDelete = (markdown, container) => {
if (!import.meta.client || !container) {
return null
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
return null
}
const range = selection.getRangeAt(0)
const elements = getSelectableEditableElements(container)
const affected = elements.filter((element) => rangeIntersectsElement(range, element))
if (!affected.length) {
return null
}
const lines = String(markdown ?? '').split('\n')
if (affected.length === 1) {
const element = affected[0]
const startsIn = element.contains(range.startContainer)
const endsIn = element.contains(range.endContainer)
if (!startsIn || !endsIn) {
return null
}
const bodyText = readEditableTextFromElement(element, { trimEnd: false })
const startOffset = getSelectionOffsetInElement(element, range, 'start')
const endOffset = getSelectionOffsetInElement(element, range, 'end')
if (startOffset === 0 && endOffset >= bodyText.length) {
const { startLine, endLine } = getEditableSourceLineRange(element)
if (startLine < 0) {
return null
}
const replacementLines = element.hasAttribute('data-empty-markdown-line')
? [element.getAttribute('data-empty-markdown-line') ?? '']
: []
return [
...lines.slice(0, startLine),
...replacementLines,
...lines.slice(endLine + 1)
].join('\n')
}
if (startOffset < endOffset) {
const { startLine } = getEditableSourceLineRange(element)
const nextLine = applyBodyEditToMarkdownLine(lines[startLine] ?? '', bodyText, startOffset, endOffset)
return [
...lines.slice(0, startLine),
nextLine,
...lines.slice(startLine + 1)
].join('\n')
}
return null
}
const first = affected[0]
const last = affected[affected.length - 1]
const { startLine: firstLine, endLine: firstEndLine } = getEditableSourceLineRange(first)
const { startLine: lastLine, endLine: lastEndLine } = getEditableSourceLineRange(last)
if (firstLine < 0 || lastLine < 0) {
return null
}
const firstBody = readEditableTextFromElement(first, { trimEnd: false })
const lastBody = readEditableTextFromElement(last, { trimEnd: false })
const startOffset = getSelectionOffsetInElement(first, range, 'start')
const endOffset = getSelectionOffsetInElement(last, range, 'end')
const mergedBody = `${firstBody.slice(0, startOffset)}${lastBody.slice(endOffset)}`
let replacementLines = []
if (mergedBody.length) {
const bodySegments = mergedBody.split('\n')
replacementLines = bodySegments.map((segment, index) => {
if (index === 0) {
return rebuildMarkdownLineBody(lines[firstLine] ?? '', firstBody, segment)
}
if (index === bodySegments.length - 1 && firstLine !== lastLine) {
return rebuildMarkdownLineBody(lines[lastLine] ?? '', lastBody, segment)
}
return segment
})
}
return [
...lines.slice(0, firstLine),
...replacementLines,
...lines.slice(lastEndLine + 1)
].join('\n')
}
/**
* 현재 문서 선택을 접는다.
* @returns {void}
*/
export const collapseLiveSelection = () => {
if (!import.meta.client) {
return
}
window.getSelection()?.removeAllRanges()
}

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.60",
"version": "1.5.70",
"private": true,
"type": "module",
"imports": {