diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 171bde3..733e7ac 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -2,6 +2,7 @@ import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js' import { resolveActiveBlockContext } from '../../lib/markdown-block-context.js' import { getImageDefaultAltLabel, serializeImageMarkdown } from '../../lib/markdown-image.js' +import { convertHtmlToMarkdown } from '../../lib/markdown-inline.js' const props = defineProps({ modelValue: { @@ -274,7 +275,10 @@ watch(() => props.modelValue, () => { watch(activeMode, (mode) => { if (mode === 'write') { - restoreTextareaFocus() + nextTick(() => { + syncTextareaHeight() + restoreTextareaFocus() + }) return } @@ -352,12 +356,12 @@ onMounted(() => { } document.addEventListener('selectionchange', onSelectionChange) - document.addEventListener('keydown', onDocumentKeydown) + document.addEventListener('keydown', onDocumentKeydown, true) onBeforeUnmount(() => { window.clearTimeout(blockPanelFocusTimer) document.removeEventListener('selectionchange', onSelectionChange) - document.removeEventListener('keydown', onDocumentKeydown) + document.removeEventListener('keydown', onDocumentKeydown, true) }) refreshCaretLogicalLine() @@ -668,6 +672,71 @@ const onPreviewGalleryReorder = ({ startLine, endLine, images }) => { ], false) } +/** + * 미리보기 인라인 편집 결과를 마크다운 본문에 반영한다. + * @param {{ startLine: number, endLine: number, replacementLines: string[] }} payload - 줄 범위·대체 줄 + * @returns {void} + */ +const onPreviewBlockContentChange = ({ startLine, endLine, replacementLines }) => { + if (typeof startLine !== 'number' || typeof endLine !== 'number' || !Array.isArray(replacementLines)) { + return + } + + replaceLineRange(startLine, endLine, replacementLines, false) +} + +/** + * 라이브 모드 하단에서 새 문단 줄을 추가한다. + * @returns {void} + */ +const onPreviewAppendParagraph = () => { + const value = markdownValue.value ?? '' + const trimmed = value.replace(/\n+$/, '') + markdownValue.value = trimmed ? `${trimmed}\n\n` : '' +} + +/** + * 라이브 모드에서 지정 줄 아래에 마크다운 줄을 삽입한다. + * @param {{ afterLine: number, lines: string[] }} payload - 삽입 위치·줄 + * @returns {void} + */ +const onPreviewInsertAfterLine = ({ afterLine, lines }) => { + if (typeof afterLine !== 'number' || !Array.isArray(lines)) { + return + } + + const sourceLines = (markdownValue.value ?? '').split('\n') + markdownValue.value = [ + ...sourceLines.slice(0, afterLine + 1), + ...lines, + ...sourceLines.slice(afterLine + 1) + ].join('\n') +} + +/** + * 라이브 모드에서 현재 줄을 삭제한다. + * @param {number} lineIndex - 줄 번호(0-based) + * @returns {void} + */ +const onPreviewDeleteLine = (lineIndex) => { + if (typeof lineIndex !== 'number' || lineIndex < 0) { + return + } + + const sourceLines = (markdownValue.value ?? '').split('\n') + + if (lineIndex >= sourceLines.length) { + return + } + + const nextLines = [ + ...sourceLines.slice(0, lineIndex), + ...sourceLines.slice(lineIndex + 1) + ] + + markdownValue.value = nextLines.length ? nextLines.join('\n') : '' +} + /** * 현재 갤러리 이미지 순서를 바꾼다. * @param {number} imageIndex - 이동할 이미지 인덱스 @@ -959,131 +1028,6 @@ const uploadAndInsert = async (files, target = 'image') => { } } -/** - * 인라인 HTML 노드를 마크다운 문자열로 변환한다. - * @param {Node} node - HTML 노드 - * @returns {string} 마크다운 문자열 - */ -const convertHtmlInlineNodeToMarkdown = (node) => { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent || '' - } - - if (node.nodeType !== Node.ELEMENT_NODE) { - return '' - } - - const element = /** @type {HTMLElement} */ (node) - const tagName = element.tagName.toLowerCase() - const childText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('') - - if (tagName === 'strong' || tagName === 'b') { - return `**${childText}**` - } - - if (tagName === 'em' || tagName === 'i') { - return `*${childText}*` - } - - if (tagName === 'code') { - return `\`${childText}\`` - } - - if (tagName === 'a') { - const href = element.getAttribute('href') - return href ? `[${childText || href}](${href})` : childText - } - - if (tagName === 'img') { - const src = element.getAttribute('src') - const alt = element.getAttribute('alt') || '' - return src ? `![${alt}](${src})` : '' - } - - if (tagName === 'br') { - return '\n' - } - - return childText -} - -/** - * HTML 블록 노드를 마크다운 문자열로 변환한다. - * @param {Node} node - HTML 노드 - * @param {number} listIndex - 순서 목록 번호 - * @returns {string} 마크다운 문자열 - */ -const convertHtmlBlockNodeToMarkdown = (node, listIndex = 1) => { - if (node.nodeType === Node.TEXT_NODE) { - return (node.textContent || '').trim() - } - - if (node.nodeType !== Node.ELEMENT_NODE) { - return '' - } - - const element = /** @type {HTMLElement} */ (node) - const tagName = element.tagName.toLowerCase() - const inlineText = Array.from(element.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim() - - if (/^h[1-6]$/.test(tagName)) { - return `${'#'.repeat(Number(tagName.slice(1)))} ${inlineText}` - } - - if (tagName === 'p') { - return inlineText - } - - if (tagName === 'blockquote') { - return inlineText.split('\n').map((line) => `> ${line}`).join('\n') - } - - if (tagName === 'pre') { - return `\`\`\`\n${element.textContent?.trim() || ''}\n\`\`\`` - } - - if (tagName === 'li') { - return `${listIndex}. ${inlineText}` - } - - if (tagName === 'ul' || tagName === 'ol') { - return Array.from(element.children) - .filter((child) => child.tagName.toLowerCase() === 'li') - .map((child, index) => { - const marker = tagName === 'ol' ? `${index + 1}.` : '-' - const text = Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('').trim() - return `${marker} ${text}` - }) - .join('\n') - } - - if (tagName === 'div' || tagName === 'section' || tagName === 'article') { - const childBlocks = Array.from(element.childNodes) - .map(convertHtmlBlockNodeToMarkdown) - .filter(Boolean) - - return childBlocks.length ? childBlocks.join('\n\n') : inlineText - } - - return inlineText -} - -/** - * 클립보드 HTML을 마크다운 문서 조각으로 변환한다. - * @param {string} html - HTML 문자열 - * @returns {string} 마크다운 문자열 - */ -const convertHtmlToMarkdown = (html) => { - const document = new DOMParser().parseFromString(html, 'text/html') - - return Array.from(document.body.childNodes) - .map(convertHtmlBlockNodeToMarkdown) - .filter(Boolean) - .join('\n\n') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - /** * 파일 입력 변경 처리 * @param {Event} event - 파일 입력 이벤트 @@ -1355,14 +1299,14 @@ const handleKeydown = (event) => { tabindex="0" > -

- 미리보기할 본문이 없습니다. -

diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue new file mode 100644 index 0000000..2c2fab1 --- /dev/null +++ b/components/content/ContentMarkdownEditableInline.vue @@ -0,0 +1,661 @@ + + + + + diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 10dba31..1819bb3 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -4,6 +4,16 @@ import { getImageDisplayCaption, parseImageMarkdownLine } from '../../lib/markdown-image.js' +import { paragraphTextToSourceLines, parseInlineSegments } from '../../lib/markdown-inline.js' +import { + hasListMarker, + hasQuoteMarker, + isEmptyListMarkerLine, + isEmptyQuoteMarkerLine, + parseOrderedListMarker, + stripListMarker, + stripQuoteMarker +} from '../../lib/markdown-live-edit.js' const props = defineProps({ content: { @@ -17,7 +27,7 @@ const props = defineProps({ } }) -const emit = defineEmits(['gallery-reorder']) +const emit = defineEmits(['gallery-reorder', 'block-content-change', 'append-paragraph', 'insert-after-line', 'delete-line']) const BLANK_PARAGRAPH_MARKER = '' @@ -27,6 +37,15 @@ const activeLightboxIndex = ref(0) const galleryDragState = ref(null) /** @type {import('vue').Ref<{ blockId: string, targetIndex: number }|null>} */ const galleryDropTarget = ref(null) +/** @type {import('vue').Ref} */ +const pendingFocusLine = ref(null) +/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */ +const pendingFocusPosition = ref('auto') +const rendererRootRef = ref(null) +/** @type {import('vue').Ref>} 원문(raw) 편집 중인 목록 줄 */ +const rawEditingSourceLines = ref(new Set()) +/** @type {number} 문단 분리 연속 호출 방지 */ +let lastParagraphSplitAt = 0 /** * 마크다운 블록을 생성 @@ -66,6 +85,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio title: options.title || '', variant: options.variant || '', ordered: options.ordered || false, + listNumbers: Array.isArray(options.listNumbers) ? options.listNumbers : [], width: options.width || 'regular', images: options.images || [], meta: options.meta && typeof options.meta === 'object' ? { ...options.meta } : {}, @@ -319,7 +339,7 @@ const parseMarkdownBlocks = (markdown) => { } blocks.push(attachSourceRange( - createBlock('quote', contentLines.join('\n').trim(), null, `block-${blocks.length}`, { variant: 'alt' }), + createBlock('quote', contentLines.join('\n'), null, `block-${blocks.length}`, { variant: 'alt' }), startLine, index - 1 )) @@ -471,7 +491,7 @@ const parseMarkdownBlocks = (markdown) => { } blocks.push(attachSourceRange( - createBlock('quote', quoteLines.join('\n').trim(), null, `block-${blocks.length}`), + createBlock('quote', quoteLines.join('\n'), null, `block-${blocks.length}`), startLine, index - 1 )) @@ -491,17 +511,20 @@ const parseMarkdownBlocks = (markdown) => { continue } - if (/^\d+\.\s+/.test(trimmedLine)) { + if (/^\d+\.\s*/.test(trimmedLine)) { const startLine = index const items = [] + const listNumbers = [] - while (index < lines.length && /^\d+\.\s+/.test(lines[index].trim())) { - items.push(lines[index].trim().replace(/^\d+\.\s+/, '')) + while (index < lines.length && /^\d+\.\s*/.test(lines[index].trim())) { + const match = lines[index].trim().match(/^(\d+)\.\s*(.*)$/) + listNumbers.push(Number(match[1])) + items.push(String(match[2] ?? '').trim()) index += 1 } blocks.push(attachSourceRange( - createBlock('list', items, null, `block-${blocks.length}`, { ordered: true }), + createBlock('list', items, null, `block-${blocks.length}`, { ordered: true, listNumbers }), startLine, index - 1 )) @@ -539,61 +562,498 @@ const parseMarkdownBlocks = (markdown) => { const blocks = computed(() => parseMarkdownBlocks(props.content)) const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value]) -/** - * 인라인 마크다운을 표시 세그먼트로 변환한다. - * @param {string} value - 원본 문자열 - * @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트 - */ -const parseInlineSegments = (value) => { - const source = String(value || '') - const segments = [] - const pattern = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g - let lastIndex = 0 - let match = pattern.exec(source) - - while (match) { - if (match.index > lastIndex) { - segments.push({ - type: 'text', - text: source.slice(lastIndex, match.index) - }) - } - - if (match[2] && match[3]) { - segments.push({ - type: 'link', - text: match[2], - href: match[3] - }) - } else if (match[4]) { - segments.push({ - type: 'strong', - text: match[4] - }) - } else if (match[5]) { - segments.push({ - type: 'code', - text: match[5] - }) - } else if (match[6]) { - segments.push({ - type: 'em', - text: match[6] - }) - } - - lastIndex = pattern.lastIndex - match = pattern.exec(source) +watch(() => props.content, () => { + if (pendingFocusLine.value === null) { + return } - if (lastIndex < source.length) { - segments.push({ - type: 'text', - text: source.slice(lastIndex) + const line = pendingFocusLine.value + const position = pendingFocusPosition.value + pendingFocusLine.value = null + pendingFocusPosition.value = 'auto' + + nextTick(() => { + nextTick(() => { + focusEditableAtLine(line, 0, position) }) + }) +}) + +/** + * 지정한 원본 줄의 편집 영역에 포커스를 둔다. + * @param {number} lineIndex - 줄 번호(0-based) + * @param {number} [attempt=0] - DOM 탐색 재시도 횟수 + * @param {'start'|'end'|'auto'} [cursorPosition='auto'] - 커서 위치 + * @returns {void} + */ +const focusEditableAtLine = (lineIndex, attempt = 0, cursorPosition = 'auto') => { + if (!import.meta.client) { + return } - return segments.length ? segments : [{ type: 'text', text: source }] + const element = rendererRootRef.value?.querySelector(`[data-source-line="${lineIndex}"]`) + + if (!element) { + if (attempt < 8) { + requestAnimationFrame(() => { + focusEditableAtLine(lineIndex, attempt + 1, cursorPosition) + }) + } + + return + } + + const line = getMarkdownLine(lineIndex) + const isBlankMarker = /^>\s*$/.test(line) || /^[-*+]\s*$/.test(line) || /^\d+\.\s*$/.test(line.trim()) + + if (isBlankMarker || !line.trim()) { + element.textContent = '' + element.innerHTML = '' + } + + element.focus() + const range = document.createRange() + range.selectNodeContents(element) + const collapseToStart = cursorPosition === 'start' + || (cursorPosition === 'auto' && (isBlankMarker || !line.trim())) + range.collapse(collapseToStart) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +/** + * 인라인 편집 원문 모드 표시 상태를 갱신한다. + * @param {{ sourceLine: number, active: boolean }} payload - 줄 번호·활성 여부 + * @returns {void} + */ +const onInlineRawMode = ({ sourceLine, active }) => { + if (typeof sourceLine !== 'number') { + return + } + + const next = new Set(rawEditingSourceLines.value) + + if (active) { + next.add(sourceLine) + } else { + next.delete(sourceLine) + } + + rawEditingSourceLines.value = next +} + +/** + * 제목 레벨별 편집 영역 클래스 + * @param {number} level - 제목 레벨 + * @returns {string} 클래스 문자열 + */ +const getHeadingEditableClass = (level) => { + const base = 'prose-heading mb-2.5 font-semibold leading-[1.25] tracking-normal first:mt-0' + + if (level === 1) { + return `${base} text-[clamp(1.35rem,1.25rem+0.35vw,1.6rem)] leading-[1.15]` + } + + if (level === 2) { + return `${base} text-[clamp(1.2rem,1.15rem+0.3vw,1.4rem)]` + } + + if (level === 3) { + return `${base} text-[clamp(1.1rem,1.05rem+0.25vw,1.25rem)]` + } + + if (level === 4) { + return `${base} text-[clamp(1.025rem,1rem+0.2vw,1.15rem)]` + } + + if (level === 5) { + return `${base} text-[clamp(0.95rem,0.925rem+0.15vw,1.05rem)]` + } + + return `${base} text-[clamp(0.9rem,0.875rem+0.1vw,1rem)]` +} + +/** + * commit 이벤트 페이로드를 정규화한다. + * @param {string|{ value: string, raw?: boolean }} payload - 페이로드 + * @returns {{ value: string, raw: boolean }} + */ +const normalizeCommitPayload = (payload) => { + if (typeof payload === 'string') { + return { value: payload, raw: false } + } + + return { + value: String(payload?.value ?? ''), + raw: payload?.raw === true + } +} + +/** + * 원본 마크다운 줄을 반환한다. + * @param {number} lineIndex - 줄 번호 + * @returns {string} 줄 텍스트 + */ +const getMarkdownLine = (lineIndex) => String(props.content || '').split('\n')[lineIndex] ?? '' + +/** + * 블록에 해당하는 원본 마크다운 줄 목록을 반환한다. + * @param {Object} block - 블록 + * @returns {string[]} 줄 목록 + */ +const getBlockSourceLines = (block) => String(props.content || '').split('\n').slice( + block.meta.startLine, + (block.meta.endLine ?? block.meta.startLine) + 1 +) + +/** + * 인용 줄 마크다운을 만든다. + * @param {string} value - 편집 값 + * @param {boolean} raw - 원문 모드 여부 + * @returns {string} 마크다운 줄 + */ +const formatQuoteLine = (value, raw) => { + if (raw) { + if (!hasQuoteMarker(value)) { + return String(value ?? '').trim() + } + + return value + } + + const clean = stripQuoteMarker(value) + return clean ? `> ${clean}` : '> ' +} + +/** + * 목록 항목 마크다운 줄을 만든다. + * @param {string} value - 편집 값 + * @param {boolean} raw - 원문 모드 여부 + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @returns {string} 마크다운 줄 + */ +/** + * 순서 목록 항목에 표시할 번호를 반환한다. + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @returns {number} 목록 번호 + */ +const getListMarkerNumber = (block, itemIndex) => { + if (block.listNumbers?.[itemIndex] != null) { + return block.listNumbers[itemIndex] + } + + const line = getMarkdownLine(block.meta.startLine + itemIndex) + const parsed = parseOrderedListMarker(line) + + return parsed?.number ?? itemIndex + 1 +} + +const formatListLine = (value, raw, block, itemIndex) => { + if (raw) { + if (!hasListMarker(value, block.ordered)) { + return String(value ?? '').trim() + } + + return value + } + + const clean = stripListMarker(value, block.ordered) + + if (block.ordered) { + const number = getListMarkerNumber(block, itemIndex) + return clean ? `${number}. ${clean}` : `${number}. ` + } + + return clean ? `- ${clean}` : '- ' +} + +/** + * 인용 블록을 줄 단위로 분리한다. + * @param {Object} block - 인용 블록 + * @returns {string[]} 줄 목록 + */ +const getQuoteLines = (block) => { + const lineCount = (block.meta.endLine ?? block.meta.startLine) - block.meta.startLine + 1 + const fromText = String(block.text ?? '').split('\n') + + while (fromText.length < lineCount) { + fromText.push('') + } + + if (!fromText.length) { + return [''] + } + + return fromText.slice(0, lineCount) +} + +/** + * 인라인 편집 결과를 마크다운 줄로 반영한다. + * @param {Object} block - 블록 + * @param {string[]} replacementLines - 대체 줄 + * @returns {void} + */ +const commitInlineBlockLines = (block, replacementLines) => { + if (!props.interactive || typeof block.meta?.startLine !== 'number') { + return + } + + emit('block-content-change', { + startLine: block.meta.startLine, + endLine: block.meta.endLine ?? block.meta.startLine, + replacementLines + }) +} + +/** + * 문단 인라인 편집 반영 + * @param {Object} block - 블록 + * @param {string} text - 편집된 텍스트 + * @returns {void} + */ +const onParagraphInlineCommit = (block, text) => { + const value = String(text ?? '') + + if (value.includes('\n')) { + commitInlineBlockLines(block, paragraphTextToSourceLines(value)) + return + } + + commitInlineBlockLines(block, [value]) +} + +/** + * 빈 줄(스페이서) 편집 반영 + * @param {Object} block - 블록 + * @param {string} text - 편집된 텍스트 + * @returns {void} + */ +const onSpacerInlineCommit = (block, text) => { + if (!String(text ?? '').trim()) { + return + } + + commitInlineBlockLines(block, [text]) +} + +/** + * 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다. + * @param {Object} block - 블록 + * @param {{ before: string, after: string }} payload - 커서 앞·뒤 텍스트 + * @returns {void} + */ +const onParagraphSplit = (block, { before, after }) => { + const now = Date.now() + + if (now - lastParagraphSplitAt < 120) { + return + } + + lastParagraphSplitAt = now + + const head = String(before ?? '') + const tail = String(after ?? '') + let replacementLines = [] + let focusLine = block.meta.startLine + + if (!tail.length) { + replacementLines = [head, ''] + focusLine = block.meta.startLine + 1 + } else if (!head.length) { + replacementLines = ['', tail] + focusLine = block.meta.startLine + 1 + } else { + replacementLines = [head, '', tail] + focusLine = block.meta.startLine + 2 + } + + pendingFocusLine.value = focusLine + commitInlineBlockLines(block, replacementLines) +} + +/** + * 블록 아래에 줄을 삽입한다. + * @param {Object} block - 블록 + * @param {{ lines?: string[] }} options - 삽입 옵션 + * @returns {void} + */ +const onInsertBelowBlock = (block, options = {}) => { + const endLine = block.meta.endLine ?? block.meta.startLine + const lines = options.lines ?? [''] + + pendingFocusLine.value = endLine + 1 + emit('insert-after-line', { + afterLine: endLine, + lines, + focusLine: endLine + 1 + }) +} + +/** + * 목록 항목 Enter — 빈 마커 줄이면 문단으로 탈출, 내용이 있으면 아래에 빈 줄만 삽입한다. + * @param {Object} block - 목록 블록 + * @param {number} itemIndex - 항목 인덱스 + * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 + * @returns {void} + */ +const onListItemInsertBelow = (block, itemIndex, payload) => { + const { value, raw } = normalizeCommitPayload(payload) + const nextLines = getBlockSourceLines(block) + + if (itemIndex < nextLines.length) { + nextLines[itemIndex] = formatListLine(value, raw, block, itemIndex) + } + + const committedLine = nextLines[itemIndex] ?? '' + + if (isEmptyListMarkerLine(committedLine, block.ordered)) { + nextLines[itemIndex] = '' + pendingFocusLine.value = block.meta.startLine + itemIndex + pendingFocusPosition.value = 'start' + commitInlineBlockLines(block, nextLines) + return + } + + nextLines.splice(itemIndex + 1, 0, '') + pendingFocusLine.value = block.meta.startLine + itemIndex + 1 + pendingFocusPosition.value = 'start' + commitInlineBlockLines(block, nextLines) +} + +/** + * 라이브 모드 하단 클릭 — 새 문단 추가 + * @returns {void} + */ +const onLiveTailClick = () => { + if (!props.interactive) { + return + } + + const lines = String(props.content || '').split('\n') + + if (!lines.length || lines[lines.length - 1] !== '') { + emit('append-paragraph') + pendingFocusLine.value = lines.length ? lines.length + 1 : 0 + return + } + + pendingFocusLine.value = lines.length - 1 + nextTick(() => { + focusEditableAtLine(lines.length - 1) + }) +} + +/** + * 제목 인라인 편집 반영 + * @param {Object} block - 블록 + * @param {string} text - 편집된 텍스트 + * @returns {void} + */ +const onHeadingInlineCommit = (block, payload) => { + const { value, raw } = normalizeCommitPayload(payload) + + if (raw) { + commitInlineBlockLines(block, [value]) + return + } + + const headingPrefix = `${'#'.repeat(Math.min(Math.max(block.level, 1), 6))} ` + const cleanText = String(value ?? '').replace(/\s+/g, ' ').trim() + commitInlineBlockLines(block, [`${headingPrefix}${cleanText}`.trimEnd() || headingPrefix.trim()]) +} + +/** + * 인용 줄 인라인 편집 반영 + * @param {Object} block - 인용 블록 + * @param {number} lineIndex - 줄 인덱스 + * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 + * @returns {void} + */ +const onQuoteLineInlineCommit = (block, lineIndex, payload) => { + const { value, raw } = normalizeCommitPayload(payload) + const sourceLines = String(props.content || '').split('\n').slice( + block.meta.startLine, + (block.meta.endLine ?? block.meta.startLine) + 1 + ) + const nextLines = [...sourceLines] + + nextLines[lineIndex] = formatQuoteLine(value, raw) + commitInlineBlockLines(block, nextLines) +} + +/** + * 인용 줄 아래에 새 인용 줄을 삽입한다. + * @param {Object} block - 인용 블록 + * @param {number} lineIndex - 줄 인덱스 + * @returns {void} + */ +const onQuoteLineInsertBelow = (block, lineIndex, payload) => { + const { value, raw } = normalizeCommitPayload(payload) + const nextLines = getBlockSourceLines(block) + + if (lineIndex < nextLines.length) { + nextLines[lineIndex] = formatQuoteLine(value, raw) + } + + const committedLine = nextLines[lineIndex] ?? '' + + if (isEmptyQuoteMarkerLine(committedLine)) { + nextLines[lineIndex] = '' + pendingFocusLine.value = block.meta.startLine + lineIndex + pendingFocusPosition.value = 'start' + commitInlineBlockLines(block, nextLines) + return + } + + nextLines.splice(lineIndex + 1, 0, '> ') + pendingFocusLine.value = block.meta.startLine + lineIndex + 1 + pendingFocusPosition.value = 'start' + commitInlineBlockLines(block, nextLines) +} + +/** + * 목록 항목 인라인 편집 반영 + * @param {Object} block - 블록 + * @param {number} itemIndex - 항목 인덱스 + * @param {string|{ value: string, raw?: boolean }} payload - 편집 내용 + * @returns {void} + */ +const onListItemInlineCommit = (block, itemIndex, payload) => { + const { value, raw } = normalizeCommitPayload(payload) + const sourceLines = String(props.content || '').split('\n').slice( + block.meta.startLine, + (block.meta.endLine ?? block.meta.startLine) + 1 + ) + + const nextLines = sourceLines.map((line, index) => { + if (index !== itemIndex) { + return line + } + + return formatListLine(value, raw, block, index) + }) + + commitInlineBlockLines(block, nextLines) +} + +/** + * 라이브 모드에서 현재 줄을 삭제한다. + * @param {number} lineIndex - 줄 번호 + * @returns {void} + */ +const onDeleteLine = (lineIndex) => { + if (typeof lineIndex !== 'number' || lineIndex < 0) { + return + } + + const focusLine = lineIndex > 0 ? lineIndex - 1 : 0 + + pendingFocusLine.value = focusLine + pendingFocusPosition.value = lineIndex > 0 ? 'end' : 'start' + emit('delete-line', lineIndex) } /** @@ -800,9 +1260,34 @@ onBeforeUnmount(() => {