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(() => { - +