From 67fbba3814d9c43367c72c03adbf43b22c73bbd9 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 4 Jun 2026 15:29:45 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=BD=9C?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=84=A0=ED=83=9D=20=EC=A0=95=EB=A0=AC=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/ContentMarkdownCalloutEditor.vue | 132 ++++-------------- .../content/ContentMarkdownEditableInline.vue | 8 +- .../content/ContentMarkdownRenderer.vue | 35 ++++- components/content/ProseCallout.vue | 4 +- docs/changelog.md | 6 + docs/deploy.md | 10 +- docs/map.md | 5 +- docs/spec.md | 4 +- docs/update.md | 7 + package-lock.json | 4 +- package.json | 2 +- 11 files changed, 100 insertions(+), 117 deletions(-) diff --git a/components/content/ContentMarkdownCalloutEditor.vue b/components/content/ContentMarkdownCalloutEditor.vue index 15a6531..d522f7f 100644 --- a/components/content/ContentMarkdownCalloutEditor.vue +++ b/components/content/ContentMarkdownCalloutEditor.vue @@ -33,19 +33,13 @@ const props = defineProps({ } }) -const emit = defineEmits(['commit', 'delete-line', 'insert-below', 'merge-with-previous', 'leave-block', 'focus-line']) +const emit = defineEmits(['commit', 'delete-line', 'insert-below', 'merge-with-previous', 'leave-block']) const bodyLines = computed(() => { const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n') return lines.length ? lines : [''] }) -const bodyLineEntries = computed(() => bodyLines.value.map((text, index) => ({ - text, - index, - sourceLine: props.bodySourceLine + index -}))) - /** * 콜아웃 마크다운 줄을 반영한다. * @param {string[]} contentLines - 본문 줄 @@ -64,96 +58,26 @@ const commitCalloutLines = (contentLines) => { } /** - * 인라인 편집 값을 문자열로 정규화한다. + * 콜아웃 본문 문자열을 줄 목록으로 정규화한다. * @param {string|{ value?: string }} payload - 편집 페이로드 - * @returns {string} 편집 값 + * @returns {string[]} 본문 줄 */ -const normalizeInlineValue = (payload) => { - if (typeof payload === 'string') { - return payload - } +const normalizeBodyLines = (payload) => { + const value = typeof payload === 'string' + ? payload + : String(payload?.value ?? '') - return String(payload?.value ?? '') + const lines = String(value ?? '').replace(/\r/g, '').split('\n') + return lines.length ? lines : [''] } /** - * 아래 줄 삽입 페이로드를 정규화한다. - * @param {string|Object} payload - 아래 줄 삽입 페이로드 - * @returns {{ value: string, before: string, after: string, caretAtStart: boolean }} - */ -const normalizeInsertPayload = (payload) => { - if (typeof payload === 'string') { - return { - value: payload, - before: '', - after: '', - caretAtStart: false - } - } - - return { - value: String(payload?.value ?? ''), - before: String(payload?.before ?? ''), - after: String(payload?.after ?? ''), - caretAtStart: payload?.caretAtStart === true - } -} - -/** - * 지정한 콜아웃 본문 줄에 포커스를 요청한다. - * @param {number} sourceLine - 원본 줄 번호 - * @returns {void} - */ -const focusCalloutLine = (sourceLine) => { - emit('focus-line', { - line: sourceLine, - position: 'start', - offset: 0 - }) -} - -/** - * 본문 한 줄 편집 반영 - * @param {number} lineIndex - 본문 줄 인덱스 + * 본문 편집 반영 * @param {string|{ value?: string }} payload - 편집 페이로드 * @returns {void} */ -const onBodyLineCommit = (lineIndex, payload) => { - const nextLines = [...bodyLines.value] - nextLines[lineIndex] = normalizeInlineValue(payload) - commitCalloutLines(nextLines) -} - -/** - * 현재 줄 아래에 콜아웃 본문 줄을 추가한다. - * @param {number} lineIndex - 본문 줄 인덱스 - * @param {Object|string} payload - 아래 줄 삽입 페이로드 - * @returns {void} - */ -const onBodyLineInsertBelow = (lineIndex, payload) => { - const { value, before, after, caretAtStart } = normalizeInsertPayload(payload) - const nextLines = [...bodyLines.value] - - if (caretAtStart && after.length) { - nextLines[lineIndex] = after - nextLines.splice(lineIndex, 0, '') - focusCalloutLine(props.bodySourceLine + lineIndex) - commitCalloutLines(nextLines) - return - } - - if (before.length && after.length) { - nextLines[lineIndex] = before - nextLines.splice(lineIndex + 1, 0, after) - focusCalloutLine(props.bodySourceLine + lineIndex + 1) - commitCalloutLines(nextLines) - return - } - - nextLines[lineIndex] = value - nextLines.splice(lineIndex + 1, 0, '') - focusCalloutLine(props.bodySourceLine + lineIndex + 1) - commitCalloutLines(nextLines) +const onBodyCommit = (payload) => { + commitCalloutLines(normalizeBodyLines(payload)) } @@ -168,27 +92,25 @@ const onBodyLineInsertBelow = (lineIndex, payload) => { >
-
- -
+
diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue index 193d72a..9da1512 100644 --- a/components/content/ContentMarkdownEditableInline.vue +++ b/components/content/ContentMarkdownEditableInline.vue @@ -45,6 +45,11 @@ const props = defineProps({ type: Number, default: null }, + /** 이 편집 영역이 대표하는 원본 줄 수 */ + sourceLineCount: { + type: Number, + default: 1 + }, /** 루트 요소 태그 */ tag: { type: String, @@ -538,7 +543,7 @@ const navigateToAdjacentBlock = (direction, column, caretMode = 'column') => { if (!target) { if ( direction === 1 - && (resolvedEnterMode.value === 'multiline' || props.arrowExitCreatesLine) + && props.arrowExitCreatesLine ) { emit('insert-below', buildInsertBelowPayload()) } @@ -1017,6 +1022,7 @@ defineExpose({ focusEditor, readEditorValue }) showingRaw ? 'content-markdown-editable-inline--raw' : '' ]" :data-source-line="sourceLine ?? undefined" + :data-source-line-end="sourceLine !== null ? sourceLine + Math.max(1, sourceLineCount) - 1 : undefined" contenteditable="true" spellcheck="true" @focus="onFocus" diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index f31dd39..fb666b7 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -858,9 +858,23 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca const matches = rendererRootRef.value ? [...rendererRootRef.value.querySelectorAll(`[data-source-line="${lineIndex}"]`)] : [] + const rangedMatches = rendererRootRef.value + ? [...rendererRootRef.value.querySelectorAll('[data-source-line][data-source-line-end]')] + .filter((node) => { + const start = Number(node.getAttribute('data-source-line')) + const end = Number(node.getAttribute('data-source-line-end')) + + return node.getAttribute('contenteditable') === 'true' + && Number.isInteger(start) + && Number.isInteger(end) + && start <= lineIndex + && lineIndex <= end + }) + : [] const element = matches.find((node) => node.getAttribute('contenteditable') === 'true') || matches[0] + || rangedMatches[0] || null if (!element) { @@ -875,8 +889,10 @@ 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 - if (isBlankMarker || !line.trim()) { + if (!isRangedLine && (isBlankMarker || !line.trim())) { if (element.getAttribute('contenteditable') === 'true') { element.textContent = '' element.innerHTML = '' @@ -896,6 +912,23 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca return } + 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) + const lineText = textLines[targetLineIndex] ?? '' + const nextOffset = cursorPosition === 'end' + ? lineOffset + lineText.length + : lineOffset + + setEditableCaretOffset(/** @type {HTMLElement} */ (element), nextOffset) + element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) + return + } + if (cursorPosition === 'start' || (cursorPosition === 'auto' && (isBlankMarker || !line.trim()))) { setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0) element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) diff --git a/components/content/ProseCallout.vue b/components/content/ProseCallout.vue index 7d633a8..e3d1296 100644 --- a/components/content/ProseCallout.vue +++ b/components/content/ProseCallout.vue @@ -41,10 +41,10 @@ const backgroundClass = computed(() => {