diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 1646402..7308ce7 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -316,6 +316,117 @@ const getLineIndexAtOffset = (offset) => { return Math.max(0, value.slice(0, safeOffset).split('\n').length - 1) } +/** + * 닫는 fenced 줄에서 대응하는 여는 줄을 찾는다. + * @param {string[]} lines - 마크다운 줄 + * @param {number} closingLine - 닫는 줄 번호 + * @param {(line: string) => boolean} isOpeningLine - 여는 줄 판별 + * @returns {number|null} 여는 줄 번호 + */ +const findPreviousFencedOpeningLine = (lines, closingLine, isOpeningLine) => { + for (let index = closingLine - 1; index >= 0; index -= 1) { + const trimmed = String(lines[index] ?? '').trim() + + if (isOpeningLine(trimmed)) { + return index + } + } + + return null +} + +/** + * 여는 fenced 줄에서 닫는 줄을 찾는다. + * @param {string[]} lines - 마크다운 줄 + * @param {number} openingLine - 여는 줄 번호 + * @param {string} closingMarker - 닫는 표식 + * @returns {number|null} 닫는 줄 번호 + */ +const findNextFencedClosingLine = (lines, openingLine, closingMarker) => { + for (let index = openingLine + 1; index < lines.length; index += 1) { + if (String(lines[index] ?? '').trim() === closingMarker) { + return index + } + } + + return null +} + +/** + * 지정 줄이 속한 코드 펜스 범위를 찾는다. + * @param {string[]} lines - 마크다운 줄 + * @param {number} targetLine - 확인할 줄 번호 + * @returns {{ openingLine: number, closingLine: number|null }|null} 코드 펜스 범위 + */ +const findCodeFenceRangeAtLine = (lines, targetLine) => { + for (let index = 0; index < lines.length; index += 1) { + if (!String(lines[index] ?? '').trim().startsWith('```')) { + continue + } + + const closingLine = findNextFencedClosingLine(lines, index, '```') + + if (targetLine === index || (closingLine !== null && targetLine >= index && targetLine <= closingLine)) { + return { openingLine: index, closingLine } + } + + if (closingLine !== null) { + index = closingLine + } + } + + return null +} + +/** + * 라이브 모드에 실제로 존재하는 편집 줄로 포커스 대상을 보정한다. + * @param {number} line - 원본 줄 번호 + * @param {number} offset - 원본 줄 내부 오프셋 + * @returns {{ line: number, offset: number }} 라이브 포커스 대상 + */ +const resolvePreviewFocusPosition = (line, offset) => { + const lines = (markdownValue.value ?? '').split('\n') + const safeLine = Math.min(Math.max(0, line), Math.max(0, lines.length - 1)) + const trimmed = String(lines[safeLine] ?? '').trim() + + if (trimmed.startsWith('```')) { + const codeFenceRange = findCodeFenceRangeAtLine(lines, safeLine) + + if (!codeFenceRange || codeFenceRange.closingLine === null) { + return { line: safeLine, offset } + } + + const focusLine = Math.min( + Math.max(codeFenceRange.openingLine + 1, safeLine), + Math.max(codeFenceRange.openingLine + 1, codeFenceRange.closingLine - 1) + ) + + return { line: focusLine, offset: safeLine === codeFenceRange.openingLine ? 0 : offset } + } + + if (trimmed.startsWith(':::callout') || trimmed.startsWith(':::toggle')) { + return { line: safeLine + 1, offset: 0 } + } + + if (trimmed === ':::') { + const previousFencedOpening = findPreviousFencedOpeningLine( + lines, + safeLine, + (candidate) => candidate.startsWith(':::callout') || candidate.startsWith(':::toggle') + ) + + if (previousFencedOpening !== null) { + return { line: Math.max(previousFencedOpening + 1, safeLine - 1), offset } + } + } + + if (/^>\s*(?:\[!bg=|\{bg=)/.test(trimmed) && String(lines[safeLine + 1] ?? '').trim().startsWith('>')) { + return { line: safeLine + 1, offset: 0 } + } + + return { line: safeLine, offset } +} + /** * 현재 textarea 선택 위치를 라이브 모드 포커스 대상으로 저장한다. * @returns {void} @@ -327,10 +438,12 @@ const rememberWritePositionForPreview = () => { ? Math.min(textarea.selectionStart, value.length) : Math.min(lastSelectionState.value.start, value.length) const line = getLineIndexAtOffset(start) + const offset = Math.max(0, start - getLineStartOffset(line)) + const focusPosition = resolvePreviewFocusPosition(line, offset) pendingPreviewFocus.value = { - line, - offset: Math.max(0, start - getLineStartOffset(line)) + line: focusPosition.line, + offset: focusPosition.offset } } @@ -1106,6 +1219,20 @@ const wrapInline = (prefix, suffix, placeholder) => { setTextareaSelection(selectionStart, selectionStart + inner.length) } +/** + * 선택 텍스트를 링크 마크다운으로 바꾼다. + * @returns {void} + */ +const insertInlineLink = () => { + const { start, end, value } = getSelectionState() + const selected = value.slice(start, end) || '링크 텍스트' + const replacement = `[${selected}](https://)` + + markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}` + const urlStart = start + selected.length + 3 + setTextareaSelection(urlStart, urlStart + 'https://'.length) +} + /** * 선택된 줄을 변환한다. * @param {(line: string) => string} transformLine - 줄 변환 함수 @@ -2819,6 +2946,10 @@ const handleKeydown = (event) => { event.preventDefault() event.stopPropagation() deleteSelectedSourceLines() + } else if (key === 'k') { + event.preventDefault() + event.stopPropagation() + insertInlineLink() } else if (key === 'b') { event.preventDefault() wrapInline('**', '**', '굵은 글씨') diff --git a/components/content/ContentMarkdownCalloutEditor.vue b/components/content/ContentMarkdownCalloutEditor.vue index 307cec0..5745868 100644 --- a/components/content/ContentMarkdownCalloutEditor.vue +++ b/components/content/ContentMarkdownCalloutEditor.vue @@ -101,6 +101,7 @@ const onBodyCommit = (payload) => { block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]" enter-mode="multiline" plain-text + arrow-exit-creates-line :source-line="bodySourceLine" :source-line-count="bodyLines.length" :model-value="modelValue" diff --git a/components/content/ContentMarkdownCodeBlockEditor.vue b/components/content/ContentMarkdownCodeBlockEditor.vue index 386e8ce..d036c6c 100644 --- a/components/content/ContentMarkdownCodeBlockEditor.vue +++ b/components/content/ContentMarkdownCodeBlockEditor.vue @@ -156,7 +156,9 @@ const toggleLineNumbers = () => { block-class="content-markdown-code-block-editor__editor m-0 min-w-0 border-0 bg-transparent p-0 font-mono text-sm leading-6 text-white outline-none" enter-mode="multiline" plain-text + arrow-exit-creates-line :source-line="bodySourceLine" + :source-line-count="bodyLines.length" :model-value="modelValue" @input="onBodyInput" @commit="onBodyCommit" diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue index 9da1512..f27c8c9 100644 --- a/components/content/ContentMarkdownEditableInline.vue +++ b/components/content/ContentMarkdownEditableInline.vue @@ -653,6 +653,43 @@ const buildInsertBelowPayload = () => { } } +/** + * 편집 영역의 현재 선택 텍스트를 링크 마크다운으로 바꾼다. + * @returns {void} + */ +const insertMarkdownLinkAtSelection = () => { + if (!import.meta.client || !rootRef.value) { + return + } + + const selection = window.getSelection() + + if (!selection || selection.rangeCount === 0) { + return + } + + const range = selection.getRangeAt(0) + + if (!rootRef.value.contains(range.commonAncestorContainer)) { + return + } + + const selectedText = selection.toString() || '링크 텍스트' + const markdown = `[${selectedText}](https://)` + + document.execCommand('insertText', false, markdown) + syncSlashState() + + nextTick(() => { + if (!rootRef.value) { + return + } + + emit('input', readEditorValue()) + emit('commit', readEditorValue()) + }) +} + /** * 커서 위치에서 문단을 분리한다. * @returns {void} @@ -792,6 +829,13 @@ const onKeydown = (event) => { return } + if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === 'k') { + event.preventDefault() + event.stopPropagation() + insertMarkdownLinkAtSelection() + return + } + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k') { if (props.sourceLine !== null) { const lineContext = getCaretLineContext() diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index a7c9aee..7bb1eb4 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -17,9 +17,14 @@ import { stripListMarker, stripQuoteMarker } from '../../lib/markdown-live-edit.js' -import { buildCodeBlockLines, parseCodeFenceLine } from '../../lib/markdown-code-block.js' +import { buildCodeBlockLines, buildCodeFenceOpener, parseCodeFenceLine } from '../../lib/markdown-code-block.js' import { buildToggleBlockLines, parseToggleOpenerLine } from '../../lib/markdown-toggle.js' -import { CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from '../../lib/markdown-callout.js' +import { + buildCalloutOpenerLine, + CALLOUT_BACKGROUND_OPTIONS, + QUOTE_BACKGROUND_OPTIONS, + parseCalloutOptions +} from '../../lib/markdown-callout.js' import { createHeadingIdFactory } from '../../lib/markdown-toc.js' import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue' import ProseCodeBlock from './ProseCodeBlock.vue' @@ -631,21 +636,29 @@ const parseMarkdownBlocks = (markdown) => { index += 1 } - blocks.push(attachSourceRange( - createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, { - codeLanguage: fenceOptions.language, - codeShowLineNumbers: fenceOptions.showLineNumbers - }), - startLine, - index - )) - index += 1 - continue + if (index >= lines.length) { + index = startLine + } else { + blocks.push(attachSourceRange( + createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`, { + codeLanguage: fenceOptions.language, + codeShowLineNumbers: fenceOptions.showLineNumbers + }), + startLine, + index + )) + index += 1 + continue + } } if (trimmedLine === '---') { const startLine = index - blocks.push(attachSourceRange(createBlock('divider', '', null, `block-${blocks.length}`), startLine, startLine)) + blocks.push(attachSourceRange( + createBlock('divider', '', null, `block-${blocks.length}`), + startLine, + startLine + )) index += 1 continue } @@ -907,6 +920,19 @@ 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) + element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) + return + } + setEditableCaretOffset(/** @type {HTMLElement} */ (element), caretOffset) element.scrollIntoView({ block: scrollBlock, inline: 'nearest' }) return @@ -1415,6 +1441,49 @@ const buildParagraphSplitLines = (head, tail) => { return [h, t] } +/** + * 문단 Enter 입력을 블록 단축 입력으로 변환할지 확인한다. + * @param {Object} block - 문단 블록 + * @param {string} before - 커서 앞 텍스트 + * @param {string} after - 커서 뒤 텍스트 + * @returns {boolean} 변환 처리 여부 + */ +const applyParagraphShortcutSplit = (block, before, after) => { + const head = String(before ?? '').trim() + const tail = String(after ?? '') + + if (tail.trim()) { + return false + } + + if (/^```[A-Za-z0-9_-]*$/.test(head)) { + const opener = head === '```' ? buildCodeFenceOpener({ language: '', showLineNumbers: true }) : head + + pendingFocusLine.value = block.meta.startLine + 1 + pendingFocusPosition.value = 'start' + commitInlineBlockLines(block, [opener, '', '```']) + return true + } + + if (head === '!!!' || head === ':::callout') { + pendingFocusLine.value = block.meta.startLine + 1 + pendingFocusPosition.value = 'start' + commitInlineBlockLines(block, [ + buildCalloutOpenerLine({ + calloutEmojiEnabled: false, + calloutEmoji: '💡', + calloutBackground: 'blue', + title: '' + }), + '', + ':::' + ]) + return true + } + + return false +} + /** * 문단 Enter 분리 — 마크다운에 빈 줄을 넣어 다음 문단을 만든다. * @param {Object} block - 블록 @@ -1430,6 +1499,10 @@ const onParagraphSplit = (block, { before, after }) => { lastParagraphSplitAt = now + if (applyParagraphShortcutSplit(block, before, after)) { + return + } + const replacementLines = buildParagraphSplitLines(before, after) const focusLine = block.meta.startLine + Math.max(replacementLines.length - 1, 1) diff --git a/components/content/ContentMarkdownToggleEditor.vue b/components/content/ContentMarkdownToggleEditor.vue index d410fd4..3aa7479 100644 --- a/components/content/ContentMarkdownToggleEditor.vue +++ b/components/content/ContentMarkdownToggleEditor.vue @@ -137,7 +137,9 @@ const onExitBelow = (payload) => { enter-mode="multiline" navigation-scope="parent" plain-text + arrow-exit-creates-line :source-line="bodySourceLine" + :source-line-count="String(modelValue ?? '').split('\n').length" :model-value="modelValue" @commit="onBodyCommit" @insert-below="onExitBelow" diff --git a/components/content/ProseBlockquote.vue b/components/content/ProseBlockquote.vue index 76ab673..723c83b 100644 --- a/components/content/ProseBlockquote.vue +++ b/components/content/ProseBlockquote.vue @@ -41,12 +41,12 @@ const backgroundClass = computed(() => {