From 6536465b12045daf9fdef1832307ec9243c899f7 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 26 May 2026 10:07:01 +0900 Subject: [PATCH] =?UTF-8?q?v1.4.7:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=9D=B8=EB=9D=BC=EC=9D=B8=20=EC=84=9C=EC=8B=9D=C2=B7=EC=9D=B8?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=B0=EA=B2=BD=C2=B7=EC=86=8C=EC=8A=A4=E2=86=92?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=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 - 라이브 모드 blur 시 인라인 마크다운(**·*)이 사라지던 문제 수정 - 인용 블록에 > [!bg=색상] 옵션으로 콜아웃과 동일한 배경 프리셋 지정 - 소스 모드에서 라이브 전환 시 현재 커서 줄을 화면 중앙에 가깝게 스크롤 --- components/admin/AdminMarkdownEditor.vue | 2 +- .../content/ContentMarkdownRenderer.vue | 123 ++++++++++++++---- components/content/ProseBlockquote.vue | 73 ++++++++++- docs/changelog.md | 6 + docs/history.md | 6 + docs/map.md | 4 +- docs/spec.md | 5 +- docs/update.md | 6 + lib/markdown-inline.js | 35 ++++- 9 files changed, 222 insertions(+), 38 deletions(-) diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index c287583..250c3f0 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -639,7 +639,7 @@ watch(activeMode, (mode) => { } nextTick(() => { - previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset) + previewRendererRef.value?.focusEditableAtLine(previewFocus.line, 0, 'auto', previewFocus.offset, 'center') }) }) }) diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 38a6f58..4102177 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -20,7 +20,7 @@ import { } from '../../lib/markdown-live-edit.js' import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js' import { buildToggleBlockLines } from '../../lib/markdown-toggle.js' -import { parseCalloutOptions } from '../../lib/markdown-callout.js' +import { CALLOUT_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' import ProseCodeBlock from './ProseCodeBlock.vue' import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue' @@ -140,6 +140,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio calloutEmojiEnabled: options.calloutEmojiEnabled ?? true, calloutEmoji: options.calloutEmoji || '💡', calloutBackground: options.calloutBackground || 'blue', + quoteBackground: options.quoteBackground || 'pink', codeLanguage: options.codeLanguage || '', codeShowLineNumbers: options.codeShowLineNumbers !== false }) @@ -161,6 +162,43 @@ const isQuoteMarkerLine = (line) => { return trimmed === '>' || /^>\s/.test(trimmed) } +/** + * 인용 마커를 제거한 본문을 반환한다. + * @param {string} line - 마크다운 행 + * @returns {string} 인용 본문 + */ +const getQuoteLineBody = (line) => String(line ?? '').trim().replace(/^>\s?/, '') + +/** + * 인용 옵션 줄을 파싱한다. + * @param {string} value - 인용 본문 줄 + * @returns {{ quoteBackground: string }|null} 인용 옵션 + */ +const parseQuoteOptions = (value) => { + const raw = String(value ?? '').trim() + const bracketMatch = raw.match(/^\[!(.+)\]$/) + const braceMatch = raw.match(/^\{(.+)\}$/) + const optionSource = bracketMatch?.[1] || braceMatch?.[1] || '' + + if (!optionSource) { + return null + } + + const tokens = optionSource.trim().split(/\s+/) + let quoteBackground = '' + + tokens.forEach((token) => { + const [key, rawOptionValue] = token.split('=') + const optionValue = String(rawOptionValue || '').trim() + + if (key?.toLowerCase() === 'bg' && CALLOUT_BACKGROUND_OPTIONS.includes(optionValue)) { + quoteBackground = optionValue + } + }) + + return quoteBackground ? { quoteBackground } : null +} + /** * 불릿 목록 마커 줄인지 확인한다. * @param {string} line - 마크다운 행 @@ -624,15 +662,24 @@ const parseMarkdownBlocks = (markdown) => { if (isQuoteMarkerLine(line)) { const startLine = index - const quoteLines = [] + const rawQuoteLines = [] while (index < lines.length && isQuoteMarkerLine(lines[index])) { - quoteLines.push(lines[index].trim().replace(/^>\s?/, '')) + rawQuoteLines.push(getQuoteLineBody(lines[index])) index += 1 } + const quoteOptions = parseQuoteOptions(rawQuoteLines[0]) + const quoteLines = quoteOptions ? rawQuoteLines.slice(1) : rawQuoteLines + const contentStartLine = startLine + (quoteOptions ? 1 : 0) + blocks.push(attachSourceRange( - createBlock('quote', quoteLines.join('\n'), null, `block-${blocks.length}`), + createBlock('quote', (quoteLines.length ? quoteLines : ['']).join('\n'), null, `block-${blocks.length}`, { + ...(quoteOptions || {}), + meta: { + quoteContentStartLine: contentStartLine + } + }), startLine, index - 1 )) @@ -785,9 +832,10 @@ watch(() => props.content, () => { * @param {number} [attempt=0] - DOM 탐색 재시도 횟수 * @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치 * @param {number|null} [caretOffset=null] - 텍스트 오프셋 + * @param {'nearest'|'center'} [scrollBlock='nearest'] - 스크롤 정렬 위치 * @returns {void} */ -const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null) => { +const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', caretOffset = null, scrollBlock = 'nearest') => { if (!import.meta.client) { return } @@ -803,7 +851,7 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca if (!element) { if (attempt < 8) { requestAnimationFrame(() => { - focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset) + focusEditableAtLine(lineIndex, attempt + 1, cursorPosition, caretOffset, scrollBlock) }) } @@ -823,28 +871,31 @@ const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto', ca element.focus({ preventScroll: true }) if (element.getAttribute('contenteditable') !== 'true') { - element.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) return } if (typeof caretOffset === 'number' && caretOffset >= 0) { setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset) + 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' }) return } if (cursorPosition === 'end') { const text = readEditableTextFromElement(/** @type {HTMLElement} */ (element)) setEditableCaretOffset(/** @type {HTMLElement} */ (element), text.length) + element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) return } setEditableCaretOffset(/** @type {HTMLElement} */ (element), 0) - element.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) } defineExpose({ @@ -1053,23 +1104,38 @@ const createEmptyListMarkerLine = (block, itemIndex) => { } /** - * 인용 블록을 줄 단위로 분리한다. + * 인용 블록의 본문 시작 줄을 반환한다. * @param {Object} block - 인용 블록 - * @returns {string[]} 줄 목록 + * @returns {number} 본문 시작 줄 */ -const getQuoteLines = (block) => { - const lineCount = (block.meta.endLine ?? block.meta.startLine) - block.meta.startLine + 1 +const getQuoteContentStartLine = (block) => { + if (typeof block.meta?.quoteContentStartLine === 'number') { + return block.meta.quoteContentStartLine + } + + return block.meta.startLine +} + +/** + * 인용 블록을 편집 가능한 줄 단위로 분리한다. + * @param {Object} block - 인용 블록 + * @returns {Array<{ text: string, sourceLine: number, sourceIndex: number }>} 줄 목록 + */ +const getQuoteLineEntries = (block) => { + const contentStartLine = getQuoteContentStartLine(block) + const endLine = block.meta.endLine ?? block.meta.startLine + const lineCount = Math.max(1, endLine - contentStartLine + 1) const fromText = String(block.text ?? '').split('\n') while (fromText.length < lineCount) { fromText.push('') } - if (!fromText.length) { - return [''] - } - - return fromText.slice(0, lineCount) + return fromText.slice(0, lineCount).map((text, index) => ({ + text, + sourceLine: contentStartLine + index, + sourceIndex: contentStartLine + index - block.meta.startLine + })) } /** @@ -2269,24 +2335,29 @@ onBeforeUnmount(() => { - +