From 113c974ee55bb064dfc92f1d1a525ff67f351b35 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 16:33:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=B8=EB=AC=B8=20=EB=AC=B8=EB=8B=A8?= =?UTF-8?q?=EA=B3=BC=20=EC=A4=84=EB=B0=94=EA=BF=88=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMarkdownEditor.vue | 106 +++++++++++++++++- .../content/ContentMarkdownRenderer.vue | 79 ++++++++++--- docs/changelog.md | 6 + docs/history.md | 10 ++ docs/map.md | 4 +- docs/spec.md | 12 +- docs/update.md | 8 ++ package-lock.json | 4 +- package.json | 2 +- 9 files changed, 200 insertions(+), 31 deletions(-) diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 3b8978c..deefe47 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -24,6 +24,11 @@ const isUploading = ref(false) const mediaPickerTarget = ref('image') const selectedMediaUrls = ref([]) const selectedImageWidth = ref('regular') +const lastSelectionState = ref({ + start: 0, + end: 0, + scrollTop: 0 +}) const imageWidthOptions = [ { value: 'regular', label: '기본' }, @@ -154,26 +159,79 @@ const refreshCaretLogicalLine = () => { const pos = Math.min(textarea.selectionStart, value.length) const lineIndex = value.slice(0, pos).split('\n').length - 1 + lastSelectionState.value = { + start: Math.min(textarea.selectionStart, value.length), + end: Math.min(textarea.selectionEnd, value.length), + scrollTop: textarea.scrollTop + } activeLogicalLineIndex.value = Math.max(0, lineIndex) syncGutterScroll() }) } /** - * textarea 스크롤 시 거터만 동기화한다. + * textarea 스크롤 시 선택 위치를 기억하고 거터를 동기화한다. * @returns {void} */ const onTextareaScroll = () => { + rememberTextareaSelection() syncGutterScroll() } +/** + * textarea의 선택 영역과 스크롤 위치를 기억한다. + * @returns {void} + */ +const rememberTextareaSelection = () => { + const textarea = textareaRef.value + const value = markdownValue.value ?? '' + + if (!textarea) { + lastSelectionState.value = { + start: value.length, + end: value.length, + scrollTop: 0 + } + return + } + + lastSelectionState.value = { + start: Math.min(textarea.selectionStart, value.length), + end: Math.min(textarea.selectionEnd, value.length), + scrollTop: textarea.scrollTop + } +} + +/** + * 기억한 선택 영역과 스크롤 위치로 작성 textarea 포커스를 복원한다. + * @returns {void} + */ +const restoreTextareaFocus = () => { + nextTick(() => { + const textarea = textareaRef.value + const value = markdownValue.value ?? '' + + if (!textarea) { + return + } + + const start = Math.min(lastSelectionState.value.start, value.length) + const end = Math.min(lastSelectionState.value.end, value.length) + + textarea.focus() + textarea.setSelectionRange(start, end) + textarea.scrollTop = lastSelectionState.value.scrollTop + refreshCaretLogicalLine() + }) +} + watch(() => props.modelValue, () => { refreshCaretLogicalLine() }) watch(activeMode, (mode) => { if (mode === 'write') { - refreshCaretLogicalLine() + restoreTextareaFocus() return } @@ -187,9 +245,30 @@ watch(activeMode, (mode) => { * @returns {void} */ const toggleEditorMode = () => { + if (activeMode.value === 'write') { + rememberTextareaSelection() + } + activeMode.value = activeMode.value === 'write' ? 'preview' : 'write' } +/** + * 작성/미리보기 모드를 지정한다. + * @param {'write'|'preview'} mode - 전환할 모드 + * @returns {void} + */ +const setEditorMode = (mode) => { + if (activeMode.value === mode) { + return + } + + if (activeMode.value === 'write') { + rememberTextareaSelection() + } + + activeMode.value = mode +} + onMounted(() => { /** * document selectionchange에서 작성 textarea가 포커스일 때만 활성 줄을 갱신한다. @@ -311,6 +390,21 @@ const replaceSelection = (replacement, cursorOffset = replacement.length, select setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0)) } +/** + * Enter 입력을 문단 분리 규칙에 맞게 처리한다. + * @param {KeyboardEvent} event - 키보드 이벤트 + * @returns {boolean} 직접 처리했는지 여부 + */ +const handleParagraphEnter = (event) => { + if (event.key !== 'Enter' || event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.isComposing) { + return false + } + + event.preventDefault() + replaceSelection('\n\n') + return true +} + /** * 블록형 마크다운 조각을 커서 위치에 삽입한다. * @param {string} snippet - 삽입할 마크다운 @@ -882,6 +976,10 @@ const handleDrop = async (event) => { * @returns {void} */ const handleKeydown = (event) => { + if (handleParagraphEnter(event)) { + return + } + if (!(event.metaKey || event.ctrlKey)) { return } @@ -956,7 +1054,7 @@ const handleKeydown = (event) => { class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold" :class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'" type="button" - @click="activeMode = 'write'" + @click="setEditorMode('write')" > 작성 @@ -964,7 +1062,7 @@ const handleKeydown = (event) => { class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold" :class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'" type="button" - @click="activeMode = 'preview'" + @click="setEditorMode('preview')" > 미리보기 diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue index 2a0b188..f110c9f 100644 --- a/components/content/ContentMarkdownRenderer.vue +++ b/components/content/ContentMarkdownRenderer.vue @@ -99,6 +99,31 @@ const parseImageLine = (line) => { } } +/** + * 독립 블록으로 해석할 수 있는 마크다운 행인지 확인한다. + * @param {string} line - 마크다운 행 + * @returns {boolean} 블록 시작 여부 + */ +const isMarkdownBlockStart = (line) => { + const trimmedLine = line.trim() + + return trimmedLine === BLANK_PARAGRAPH_MARKER || + trimmedLine === '>>>' || + trimmedLine === ':::bookmark' || + trimmedLine === ':::signup' || + trimmedLine === ':::gallery' || + trimmedLine === ':::embed' || + trimmedLine.startsWith(':::callout') || + trimmedLine.startsWith(':::toggle') || + trimmedLine.startsWith('```') || + trimmedLine === '---' || + /^(#{1,6})\s+(.+)$/.test(trimmedLine) || + trimmedLine.startsWith('> ') || + /^- /.test(trimmedLine) || + /^\d+\.\s+/.test(trimmedLine) || + Boolean(parseImageLine(trimmedLine)) +} + /** * 닫힘 표식까지의 행 목록을 반환 * @param {Array} lines - 전체 마크다운 행 @@ -224,14 +249,7 @@ const parseMarkdownBlocks = (markdown) => { const line = lines[index] const trimmedLine = line.trim() - if (trimmedLine === BLANK_PARAGRAPH_MARKER) { - blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`)) - index += 1 - continue - } - - if (!trimmedLine) { - blocks.push(createBlock('spacer', '', null, `block-${blocks.length}`)) + if (trimmedLine === BLANK_PARAGRAPH_MARKER || !trimmedLine) { index += 1 continue } @@ -380,8 +398,22 @@ const parseMarkdownBlocks = (markdown) => { continue } - blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`)) + const paragraphLines = [trimmedLine] index += 1 + + while (index < lines.length) { + const nextLine = lines[index] + const nextTrimmedLine = nextLine.trim() + + if (!nextTrimmedLine || isMarkdownBlockStart(nextTrimmedLine)) { + break + } + + paragraphLines.push(nextTrimmedLine) + index += 1 + } + + blocks.push(createBlock('paragraph', paragraphLines.join('\n'), null, `block-${blocks.length}`)) } return blocks @@ -447,6 +479,15 @@ const parseInlineSegments = (value) => { return segments.length ? segments : [{ type: 'text', text: source }] } +/** + * 줄바꿈이 포함된 인라인 마크다운을 줄 단위 세그먼트로 변환한다. + * @param {string} value - 원본 문자열 + * @returns {Array>} 줄별 인라인 세그먼트 + */ +const parseInlineSegmentLines = (value) => { + return String(value || '').split('\n').map(parseInlineSegments) +} + /** * 라이트박스를 연다 * @param {Array} images - 이미지 목록 @@ -489,8 +530,7 @@ const showNextImage = () => {