v1.2.8: 라이브 모드 인라인 편집 및 목록·인용 동작 개선

관리자 미리보기에서 문단·목록·인용을 contenteditable로 편집하고, Cmd+E 전환·사용자 지정 순서 번호·줄 삭제·화살표 줄 이동을 지원한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-18 12:01:11 +09:00
parent 0c051cbe3b
commit 666bd304fc
10 changed files with 1893 additions and 197 deletions

View File

@@ -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"
>
<ContentMarkdownRenderer
v-if="markdownValue.trim()"
:content="markdownValue"
interactive
@gallery-reorder="onPreviewGalleryReorder"
@block-content-change="onPreviewBlockContentChange"
@append-paragraph="onPreviewAppendParagraph"
@insert-after-line="onPreviewInsertAfterLine"
@delete-line="onPreviewDeleteLine"
/>
<p v-else class="admin-markdown-editor__preview-empty text-sm text-[#8e9cac]">
미리보기할 본문이 없습니다.
</p>
</div>
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">

View File

@@ -0,0 +1,661 @@
<script setup>
import {
convertEditableHtmlToMarkdown,
getRangeInnerHtml,
markdownInlineToHtml,
markdownMultilineInlineToHtml
} from '../../lib/markdown-inline.js'
const props = defineProps({
/** 인라인 마크다운 원문 */
modelValue: {
type: String,
default: ''
},
/** 여러 줄(Shift+Enter 줄바꿈) 허용 */
multiline: {
type: Boolean,
default: false
},
/**
* Enter 동작
* - split-paragraph: 문단 분리
* - insert-below: 블록 아래 새 줄 삽입
* - none: 기본(제목 등 단일 줄 blur)
*/
enterMode: {
type: String,
default: 'none'
},
/** @deprecated enterMode 사용 */
splitOnEnter: {
type: Boolean,
default: false
},
/** Shift+Enter 줄바꿈 허용 */
allowHardBreak: {
type: Boolean,
default: false
},
/** 원본 마크다운 줄 번호(0-based) */
sourceLine: {
type: Number,
default: null
},
/** 루트 요소 태그 */
tag: {
type: String,
default: 'div'
},
/** 추가 클래스 */
blockClass: {
type: String,
default: ''
},
/** 마크다운 원문 한 줄(백스페이스 시 표시) */
rawLine: {
type: String,
default: ''
},
/** 맨 앞 백스페이스로 원문 토글 허용 */
allowRawToggle: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['commit', 'split', 'insert-below', 'delete-line', 'raw-mode'])
const rootRef = ref(null)
const isFocused = ref(false)
const suppressBlurCommit = ref(false)
const splitLock = ref(false)
/** 조합 중 Enter 후 compositionend에서 분리할지 */
const pendingSplitAfterComposition = ref(false)
const showingRaw = ref(false)
/** @returns {string} Enter 동작 모드 */
const resolvedEnterMode = computed(() => {
if (props.enterMode !== 'none') {
return props.enterMode
}
return props.splitOnEnter ? 'split-paragraph' : 'none'
})
/**
* modelValue를 편집용 HTML로 변환한다.
* @returns {string} HTML
*/
const toEditorHtml = () => (props.multiline
? markdownMultilineInlineToHtml(props.modelValue)
: markdownInlineToHtml(props.modelValue))
/**
* 편집 영역 HTML을 동기화한다.
* @returns {void}
*/
const syncEditorHtml = () => {
if (!rootRef.value || isFocused.value) {
return
}
if (showingRaw.value && props.rawLine) {
rootRef.value.textContent = props.rawLine
return
}
rootRef.value.innerHTML = toEditorHtml()
}
watch(() => [props.modelValue, props.rawLine], () => {
if (!isFocused.value) {
showingRaw.value = false
}
syncEditorHtml()
})
onMounted(() => {
syncEditorHtml()
})
/**
* 포커스 시 편집 상태를 표시한다.
* @returns {void}
*/
const onFocus = () => {
isFocused.value = true
}
/**
* blur 시 마크다운을 반영한다.
* @returns {void}
*/
/**
* 편집 영역 값을 읽는다.
* @returns {string} 텍스트
*/
const readEditorValue = () => {
if (!rootRef.value) {
return ''
}
if (showingRaw.value) {
return rootRef.value.textContent ?? ''
}
return convertEditableHtmlToMarkdown(rootRef.value.innerHTML)
}
const onBlur = () => {
isFocused.value = false
if (!rootRef.value || suppressBlurCommit.value || splitLock.value) {
return
}
const nextValue = readEditorValue()
const changed = showingRaw.value
? nextValue !== props.rawLine
: nextValue !== props.modelValue
if (changed) {
emit('commit', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
}
if (showingRaw.value) {
showingRaw.value = false
notifyRawMode(false)
}
syncEditorHtml()
}
/**
* 붙여넣기 시 서식 없는 텍스트만 삽입한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트
* @returns {void}
*/
const onPaste = (event) => {
event.preventDefault()
const text = event.clipboardData?.getData('text/plain') || ''
if (!text || !import.meta.client) {
return
}
document.execCommand('insertText', false, text)
}
/**
* 커서가 블록 맨 앞/맨 끝인지 확인한다.
* @param {'start'|'end'} edge - 확인할 위치
* @returns {boolean}
*/
const isCaretAtEdge = (edge) => {
if (!import.meta.client || !rootRef.value) {
return false
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return false
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return false
}
const testRange = document.createRange()
testRange.selectNodeContents(rootRef.value)
if (edge === 'start') {
testRange.setEnd(range.startContainer, range.startOffset)
return testRange.toString().length === 0
}
testRange.setStart(range.endContainer, range.endOffset)
return testRange.toString().length === 0
}
/**
* 커서의 텍스트 오프셋을 반환한다.
* @returns {number} 루트 기준 오프셋
*/
const getCaretTextOffset = () => {
if (!import.meta.client || !rootRef.value) {
return 0
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
return 0
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return 0
}
const testRange = document.createRange()
testRange.selectNodeContents(rootRef.value)
testRange.setEnd(range.startContainer, range.startOffset)
return testRange.toString().length
}
/**
* 원문 모드 접두사 길이를 반환한다.
* @returns {number} 접두사 길이
*/
const getRawPrefixLength = () => {
if (!showingRaw.value || !rootRef.value) {
return 0
}
const text = rootRef.value.textContent ?? ''
const match = text.match(/^(\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+))/)
return match ? match[1].length : 0
}
/**
* 커서가 줄의 논리적 맨 앞인지 확인한다(원문 접두사 직후 포함).
* @returns {boolean}
*/
const isCaretAtLogicalStart = () => {
if (isCaretAtEdge('start')) {
return true
}
const prefixLength = getRawPrefixLength()
if (!prefixLength) {
return false
}
return getCaretTextOffset() <= prefixLength
}
/**
* 커서가 줄의 논리적 맨 끝인지 확인한다.
* @returns {boolean}
*/
const isCaretAtLogicalEnd = () => isCaretAtEdge('end')
/**
* 이전·다음 편집 줄로 포커스를 옮긴다.
* @param {number} direction - -1 이전 줄, 1 다음 줄
* @param {boolean} cursorAtStart - 커서를 줄 앞에 둘지
* @returns {void}
*/
const navigateToAdjacentLine = (direction, cursorAtStart) => {
if (!import.meta.client || props.sourceLine === null) {
return
}
const container = rootRef.value?.closest('.content-markdown-renderer')
if (!container) {
return
}
const elements = [...container.querySelectorAll('[data-source-line]')]
.sort((left, right) => Number(left.dataset.sourceLine) - Number(right.dataset.sourceLine))
const currentIndex = elements.findIndex(
(element) => Number(element.dataset.sourceLine) === props.sourceLine
)
if (currentIndex < 0) {
return
}
const target = elements[currentIndex + direction]
if (!target) {
return
}
target.focus()
const range = document.createRange()
range.selectNodeContents(target)
range.collapse(cursorAtStart)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
/**
* 커서 위치에서 문단을 분리한다.
* @returns {void}
*/
const splitAtCaret = () => {
if (!import.meta.client || !rootRef.value) {
return
}
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) {
emit('split', { before: props.modelValue, after: '' })
return
}
const range = selection.getRangeAt(0)
if (!rootRef.value.contains(range.commonAncestorContainer)) {
return
}
const beforeRange = document.createRange()
beforeRange.selectNodeContents(rootRef.value)
beforeRange.setEnd(range.startContainer, range.startOffset)
const afterRange = document.createRange()
afterRange.setStart(range.endContainer, range.endOffset)
if (rootRef.value.lastChild) {
afterRange.setEndAfter(rootRef.value.lastChild)
} else {
afterRange.setEnd(rootRef.value, 0)
}
const before = convertEditableHtmlToMarkdown(getRangeInnerHtml(beforeRange))
const after = convertEditableHtmlToMarkdown(getRangeInnerHtml(afterRange))
emit('split', { before, after })
}
/**
* Enter 처리(분리·아래 삽입)를 한 번만 실행한다.
* @param {'split'|'insert-below'} action - 동작
* @returns {void}
*/
const scheduleEnterAction = (action) => {
if (splitLock.value) {
return
}
splitLock.value = true
suppressBlurCommit.value = true
if (action === 'split') {
splitAtCaret()
} else {
const nextValue = readEditorValue()
emit('insert-below', showingRaw.value ? { value: nextValue, raw: true } : nextValue)
}
nextTick(() => {
splitLock.value = false
window.setTimeout(() => {
suppressBlurCommit.value = false
}, 50)
})
}
/**
* 원문 모드 상태를 부모에 알린다.
* @param {boolean} active - 활성 여부
* @returns {void}
*/
const notifyRawMode = (active) => {
if (props.sourceLine === null) {
return
}
emit('raw-mode', { sourceLine: props.sourceLine, active })
}
/**
* 원문 모드 커서를 접두사 뒤에 둔다.
* @returns {void}
*/
const placeCursorAfterPrefix = () => {
if (!rootRef.value) {
return
}
const text = rootRef.value.textContent ?? ''
const match = text.match(/^(\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+\.\s+))/)
if (!match) {
return
}
const offset = match[1].length
const walker = document.createTreeWalker(rootRef.value, NodeFilter.SHOW_TEXT)
let node = walker.nextNode()
let remaining = offset
while (node) {
const length = node.textContent?.length ?? 0
if (remaining <= length) {
const range = document.createRange()
range.setStart(node, remaining)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return
}
remaining -= length
node = walker.nextNode()
}
}
/**
* 키보드 입력 처리
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const onKeydown = (event) => {
const isCmdE = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'e'
if (isCmdE) {
return
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === 'k') {
if (props.sourceLine !== null) {
event.preventDefault()
event.stopPropagation()
emit('delete-line', props.sourceLine)
}
return
}
if (
event.key === 'Backspace'
&& isCaretAtEdge('start')
&& !showingRaw.value
&& props.sourceLine !== null
&& !readEditorValue().trim()
) {
event.preventDefault()
event.stopPropagation()
emit('delete-line', props.sourceLine)
return
}
if (
event.key === 'Backspace'
&& props.allowRawToggle
&& props.rawLine
&& !showingRaw.value
&& isCaretAtEdge('start')
) {
event.preventDefault()
event.stopPropagation()
showingRaw.value = true
notifyRawMode(true)
nextTick(() => {
if (!rootRef.value) {
return
}
rootRef.value.textContent = props.rawLine
placeCursorAfterPrefix()
})
return
}
if (event.key === 'ArrowUp' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(-1, false)
return
}
if (event.key === 'ArrowDown' && isCaretAtLogicalEnd()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(1, true)
return
}
if (event.key === 'ArrowLeft' && isCaretAtLogicalStart()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(-1, false)
return
}
if (event.key === 'ArrowRight' && isCaretAtLogicalEnd()) {
event.preventDefault()
event.stopPropagation()
navigateToAdjacentLine(1, true)
return
}
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (event.key === 'Enter' && !event.shiftKey && (enterMode === 'split-paragraph' || enterMode === 'insert-below')) {
event.preventDefault()
event.stopPropagation()
if (event.isComposing || event.keyCode === 229) {
pendingSplitAfterComposition.value = true
return
}
pendingSplitAfterComposition.value = false
scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below')
return
}
if (event.key === 'Enter' && event.shiftKey && props.allowHardBreak) {
return
}
if (event.key === 'Enter' && !props.multiline && enterMode === 'none') {
event.preventDefault()
event.stopPropagation()
rootRef.value?.blur()
}
}
/**
* 한글 등 IME 조합 종료 후 Enter 처리
* @returns {void}
*/
const onCompositionEnd = () => {
if (!pendingSplitAfterComposition.value) {
return
}
pendingSplitAfterComposition.value = false
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') {
return
}
nextTick(() => {
scheduleEnterAction(enterMode === 'split-paragraph' ? 'split' : 'insert-below')
})
}
/**
* 외부에서 포커스·커서를 둔다.
* @param {'start'|'end'} position - 커서 위치
* @returns {void}
*/
const focusEditor = (position = 'end') => {
if (!import.meta.client || !rootRef.value) {
return
}
rootRef.value.focus()
const range = document.createRange()
range.selectNodeContents(rootRef.value)
range.collapse(position === 'start')
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
defineExpose({ focusEditor })
</script>
<template>
<component
:is="tag"
ref="rootRef"
class="content-markdown-editable-inline min-w-0 outline-none"
:class="[
blockClass,
isFocused ? 'content-markdown-editable-inline--focused' : 'content-markdown-editable-inline--idle',
showingRaw ? 'content-markdown-editable-inline--raw' : ''
]"
:data-source-line="sourceLine ?? undefined"
contenteditable="true"
spellcheck="true"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
@keydown="onKeydown"
@compositionend="onCompositionEnd"
/>
</template>
<style scoped>
.content-markdown-editable-inline--idle {
cursor: text;
border-radius: 4px;
}
.content-markdown-editable-inline--focused {
background-color: transparent;
}
.content-markdown-editable-inline--raw {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.92em;
color: #5c6570;
}
.content-markdown-editable-inline :deep(a) {
color: var(--site-accent);
text-decoration: underline;
text-underline-offset: 4px;
}
.content-markdown-editable-inline :deep(code) {
border-radius: 0.25rem;
background: #252525;
padding: 0.125rem 0.375rem;
font-size: 0.9em;
color: #fff;
}
</style>

View File

@@ -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 = '<!--sori:blank-paragraph-->'
@@ -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<number|null>} */
const pendingFocusLine = ref(null)
/** @type {import('vue').Ref<'start'|'end'|'auto'>} 포커스 후 커서 위치 */
const pendingFocusPosition = ref('auto')
const rendererRootRef = ref(null)
/** @type {import('vue').Ref<Set<number>>} 원문(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(() => {
</script>
<template>
<div class="content-markdown-renderer">
<div ref="rendererRootRef" class="content-markdown-renderer">
<template v-for="block in blocks" :key="block.id">
<div v-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
<ContentMarkdownEditableInline
v-if="block.type === 'spacer' && interactive"
tag="p"
block-class="content-markdown-renderer__paragraph content-markdown-renderer__spacer-line text-base text-[var(--site-text)]"
enter-mode="split-paragraph"
allow-hard-break
:source-line="block.meta.startLine"
:model-value="''"
@commit="onSpacerInlineCommit(block, $event)"
@split="onParagraphSplit(block, $event)"
@delete-line="onDeleteLine"
/>
<div v-else-if="block.type === 'spacer'" class="content-markdown-renderer__spacer" :class="getSpacerHeightClass(block)" aria-hidden="true" />
<ContentMarkdownEditableInline
v-else-if="block.type === 'heading' && interactive"
:tag="`h${Math.min(Math.max(block.level, 1), 6)}`"
:block-class="getHeadingEditableClass(block.level)"
enter-mode="insert-below"
allow-raw-toggle
:raw-line="getMarkdownLine(block.meta.startLine)"
:source-line="block.meta.startLine"
:model-value="block.text"
@commit="onHeadingInlineCommit(block, $event)"
@insert-below="onInsertBelowBlock(block)"
@delete-line="onDeleteLine"
/>
<ProseHeading v-else-if="block.type === 'heading'" :level="block.level">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-heading-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
@@ -812,6 +1297,25 @@ onBeforeUnmount(() => {
<template v-else>{{ segment.text }}</template>
</template>
</ProseHeading>
<ProseBlockquote
v-else-if="block.type === 'quote' && interactive && block.variant !== 'alt'"
:variant="block.variant || 'default'"
>
<ContentMarkdownEditableInline
v-for="(quoteLine, quoteLineIndex) in getQuoteLines(block)"
:key="`quote-line-${block.meta.startLine + quoteLineIndex}`"
block-class="content-markdown-renderer__quote-line"
:model-value="quoteLine"
enter-mode="insert-below"
allow-raw-toggle
:raw-line="getMarkdownLine(block.meta.startLine + quoteLineIndex)"
:source-line="block.meta.startLine + quoteLineIndex"
@commit="onQuoteLineInlineCommit(block, quoteLineIndex, $event)"
@insert-below="onQuoteLineInsertBelow(block, quoteLineIndex, $event)"
@delete-line="onDeleteLine"
@raw-mode="onInlineRawMode"
/>
</ProseBlockquote>
<ProseBlockquote v-else-if="block.type === 'quote'" :variant="block.variant || 'default'">
<template v-for="(segment, segmentIndex) in parseInlineSegments(block.text)" :key="`${block.id}-quote-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
@@ -821,8 +1325,54 @@ onBeforeUnmount(() => {
<template v-else>{{ segment.text }}</template>
</template>
</ProseBlockquote>
<ProseList v-else-if="block.type === 'list' && interactive" :ordered="block.ordered || false">
<li
v-for="(item, itemIndex) in block.text"
:key="`list-line-${block.meta.startLine + itemIndex}`"
class="content-markdown-renderer__list-item flex items-center gap-2"
:class="rawEditingSourceLines.has(block.meta.startLine + itemIndex) ? 'content-markdown-renderer__list-item--raw' : ''"
>
<span
v-if="block.ordered"
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--ordered"
aria-hidden="true"
>{{ getListMarkerNumber(block, itemIndex) }}.</span>
<span
v-else
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--bullet"
aria-hidden="true"
/>
<ContentMarkdownEditableInline
class="min-w-0 flex-1"
:model-value="item"
enter-mode="insert-below"
allow-raw-toggle
:raw-line="getMarkdownLine(block.meta.startLine + itemIndex)"
:source-line="block.meta.startLine + itemIndex"
@commit="onListItemInlineCommit(block, itemIndex, $event)"
@insert-below="onListItemInsertBelow(block, itemIndex, $event)"
@delete-line="onDeleteLine"
@raw-mode="onInlineRawMode"
/>
</li>
</ProseList>
<ProseList v-else-if="block.type === 'list'" :ordered="block.ordered || false">
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
<li
v-for="(item, itemIndex) in block.text"
:key="`${block.id}-${itemIndex}`"
class="content-markdown-renderer__list-item flex items-center gap-2"
>
<span
v-if="block.ordered"
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--ordered"
aria-hidden="true"
>{{ getListMarkerNumber(block, itemIndex) }}.</span>
<span
v-else
class="content-markdown-renderer__list-marker content-markdown-renderer__list-marker--bullet"
aria-hidden="true"
/>
<span class="min-w-0 flex-1">
<template v-for="(segment, segmentIndex) in parseInlineSegments(item)" :key="`${block.id}-${itemIndex}-${segmentIndex}`">
<strong v-if="segment.type === 'strong'">{{ segment.text }}</strong>
<em v-else-if="segment.type === 'em'">{{ segment.text }}</em>
@@ -830,6 +1380,7 @@ onBeforeUnmount(() => {
<a v-else-if="segment.type === 'link'" class="text-[var(--site-accent)] underline underline-offset-4" :href="segment.href" target="_blank" rel="noreferrer">{{ segment.text }}</a>
<template v-else>{{ segment.text }}</template>
</template>
</span>
</li>
</ProseList>
<ProseImage
@@ -921,7 +1472,19 @@ onBeforeUnmount(() => {
class="content-markdown-renderer__code overflow-x-auto rounded bg-[#15171a] px-4 py-3 mb-2.5 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-5 border-line">
<p v-else class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
<ContentMarkdownEditableInline
v-else-if="block.type === 'paragraph' && interactive"
tag="p"
block-class="content-markdown-renderer__paragraph content-markdown-renderer__paragraph--editable mb-2.5 min-h-[1.75rem] text-base text-[var(--site-text)] last:mb-0"
enter-mode="split-paragraph"
allow-hard-break
:source-line="block.meta.startLine"
:model-value="block.text"
@commit="onParagraphInlineCommit(block, $event)"
@split="onParagraphSplit(block, $event)"
@delete-line="onDeleteLine"
/>
<p v-else-if="block.type === 'paragraph'" class="content-markdown-renderer__paragraph mb-2.5 text-base text-[var(--site-text)] last:mb-0">
<template v-for="(lineSegments, lineIndex) in parseInlineSegmentLines(block.text)" :key="`${block.id}-paragraph-line-${lineIndex}`">
<br v-if="lineIndex > 0">
<template v-for="(segment, segmentIndex) in lineSegments" :key="`${block.id}-paragraph-${lineIndex}-${segmentIndex}`">
@@ -935,6 +1498,15 @@ onBeforeUnmount(() => {
</p>
</template>
<div
v-if="interactive"
class="content-markdown-renderer__live-tail mt-1 min-h-[120px] flex-1 cursor-text"
role="button"
tabindex="-1"
aria-label=" 문단 추가"
@mousedown.prevent="onLiveTailClick"
/>
<div
v-if="activeLightboxImage"
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
@@ -973,6 +1545,54 @@ onBeforeUnmount(() => {
</template>
<style scoped>
.content-markdown-renderer__spacer-line {
margin-bottom: 0;
min-height: 1.75rem;
padding: 0;
}
.content-markdown-renderer__quote-line {
margin-bottom: 0.25rem;
}
.content-markdown-renderer__quote-line:last-child {
margin-bottom: 0;
}
.content-markdown-renderer__list-marker {
width: 21px;
flex-shrink: 0;
}
.content-markdown-renderer__list-marker--ordered {
text-align: right;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--site-accent);
}
.content-markdown-renderer__list-marker--bullet {
display: flex;
align-items: center;
justify-content: flex-end;
}
.content-markdown-renderer__list-marker--bullet::after {
width: 6px;
height: 6px;
border-radius: 9999px;
background-color: var(--site-accent);
content: '';
}
.content-markdown-renderer__list-item--raw .content-markdown-renderer__list-marker {
opacity: 0.35;
}
.content-markdown-renderer__paragraph--editable:empty {
min-height: 1.75rem;
}
.content-markdown-renderer__gallery-item--interactive {
cursor: grab;
}

View File

@@ -10,8 +10,7 @@ defineProps({
<template>
<component
:is="ordered ? 'ol' : 'ul'"
class="prose-list mb-2.5 space-y-2 pl-5 text-[15px] leading-8 text-[var(--site-text)] marker:text-[var(--site-muted)]"
:class="ordered ? 'list-decimal' : 'list-disc'"
class="prose-list mb-2.5 list-none space-y-2 pl-0 text-[15px] leading-8 text-[var(--site-text)]"
>
<slot />
</component>

View File

@@ -480,6 +480,7 @@ components/content/
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
- 관리자 미리보기에서 `ContentMarkdownRenderer``interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다.
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록 항목을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**는 다음 문단(마크다운 빈 줄), **Shift+Enter**만 같은 문단 내 줄바꿈. 본문 하단 클릭으로 새 문단을 추가한다. 변경은 `block-content-change`·`append-paragraph`로 소스에 반영되며 소스 모드 줄 번호와 일치한다.
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.

View File

@@ -2,7 +2,7 @@
## 1차 관리자 개발
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
## 2차 관리자 개발

View File

@@ -1,5 +1,35 @@
# 업데이트 이력
## v1.2.8
- 라이브 모드: 인용·목록 줄 단위 편집, `> `·`- ` 접두사 중복 표시 제거. 맨 앞 백스페이스로 마크다운 원문(`- 리스트 1` 등) 표시. Cmd+Shift+K 현재 줄 삭제.
- 라이브 모드 Cmd+E: contenteditable에서도 소스 모드 전환(capture 단계 리스너). `keydown.stop` 제거.
- 라이브 모드 편집 UI: 포커스 시 파란 보더·배경 제거(커서만), 호버는 연한 회색 배경만(포커스 중 호버 없음).
- 라이브 모드 Enter(인용·목록): commit+삽입을 한 번에 반영해 2번째 줄 Enter 시 텍스트 복사 방지. `>` 제거 후 Enter 시 일반 문단으로 이탈.
- 라이브 모드 Enter(목록): 다음 줄 `- `·`2. ` 자동 삽입 제거. 내용 있으면 빈 줄만 추가, 빈 마커 줄 Enter 시 문단 탈출. 인용은 `> ` 이어쓰기 유지.
- 순서 목록: 소스의 숫자(4., 27. 등)를 렌더·commit에 반영(자동 재번호 없음). 목록 마커 `--site-accent` 스타일.
- 라이브 모드 목록: 디스크 마커 세로 중앙 정렬(`items-center`). Enter·줄 삭제 후 포커스 유지(retry). 빈 줄 Backspace/Cmd+Shift+K 삭제.
- 목록 마커 열 너비 21px·오른쪽 정렬(숫자·디스크 공통, 본문 시작선 정렬).
- 빈 줄(spacer) Cmd+Shift+K 삭제. 줄 삭제 후 이전 줄 끝 포커스. 호버 배경 제거.
- 라이브 모드 raw: 원문 모드 Enter 시 브라우저 기본 줄바꿈 대신 아래 줄 삽입. 마커(`>`·`-`) 제거 시 일반 문단으로 저장. 목록 raw 시 불릿 숨김.
- 라이브 모드 Enter: 커서 뒤 텍스트 잘림 버그 수정(문단 끝에서 이전 줄 내용이 복사되지 않음). 끝 Enter 시 빈 줄 1개만 삽입.
- 제목·인용·목록: 블록 안 줄바꿈 대신 아래 새 블록/항목 삽입, 여백 축소.
- 라이브 모드 ↑↓←→: 줄 경계에서 이전·다음 편집 영역 이동(끝→/↓ 다음 줄 앞, 앞←/↑ 이전 줄 끝, 원문 접두사 직후 포함).
- 패키지 버전 `1.2.8`로 갱신.
## v1.2.7
- 라이브 모드: Enter 시 다음 문단 생성(빈 줄 삽입), Shift+Enter만 같은 문단 내 줄바꿈. 하단 클릭 영역으로 새 문단 추가. 빈 줄도 편집 가능.
- 라이브 모드 한글 IME: 조합 중 Enter는 compositionend 후 1회만 분리, blur 중복 commit·연속 split 방지.
- 소스 모드 전환 시 textarea 높이·줄 번호 거터 동기화 보강.
- 패키지 버전 `1.2.7`로 갱신.
## v1.2.6
- 관리자 미리보기 인라인 편집: 문단·제목·인용·목록 항목을 contenteditable로 편집, blur 시 마크다운 본문 반영(`block-content-change`).
- `lib/markdown-inline.js`, `ContentMarkdownEditableInline.vue` 추가. HTML↔인라인 마크다운 변환 공유.
- 패키지 버전 `1.2.6`으로 갱신.
## v1.2.5
- 관리자 미리보기 갤러리: 드래그 중 드롭 대상 셀에 주황 테두리·「여기로 이동」 오버레이 표시, 드래그 중 원본 셀 반투명 처리.

311
lib/markdown-inline.js Normal file
View File

@@ -0,0 +1,311 @@
/** @type {RegExp} 인라인 마크다운 패턴 */
const INLINE_MARKDOWN_RE = /(\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
/**
* HTML 특수문자 이스케이프
* @param {string} value - 원본 문자열
* @returns {string} 이스케이프된 문자열
*/
export const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
* @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트
*/
export const parseInlineSegments = (value) => {
const source = String(value || '')
const segments = []
let lastIndex = 0
INLINE_MARKDOWN_RE.lastIndex = 0
let match = INLINE_MARKDOWN_RE.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 = INLINE_MARKDOWN_RE.lastIndex
match = INLINE_MARKDOWN_RE.exec(source)
}
if (lastIndex < source.length) {
segments.push({
type: 'text',
text: source.slice(lastIndex)
})
}
return segments.length ? segments : [{ type: 'text', text: source }]
}
/**
* 인라인 세그먼트를 HTML 문자열로 변환한다.
* @param {Array<{ type: string, text: string, href?: string }>} segments - 세그먼트
* @returns {string} HTML
*/
export const segmentsToInlineHtml = (segments) => segments.map((segment) => {
if (segment.type === 'strong') {
return `<strong>${escapeHtml(segment.text)}</strong>`
}
if (segment.type === 'em') {
return `<em>${escapeHtml(segment.text)}</em>`
}
if (segment.type === 'code') {
return `<code>${escapeHtml(segment.text)}</code>`
}
if (segment.type === 'link') {
return `<a href="${escapeHtml(segment.href || '')}">${escapeHtml(segment.text)}</a>`
}
return escapeHtml(segment.text)
}).join('')
/**
* 인라인 마크다운(단일 줄)을 HTML로 변환한다.
* @param {string} value - 마크다운 문자열
* @returns {string} HTML
*/
export const markdownInlineToHtml = (value) => segmentsToInlineHtml(parseInlineSegments(value))
/**
* 여러 줄 인라인 마크다운을 HTML로 변환한다.
* @param {string} value - 줄바꿈 포함 마크다운
* @returns {string} HTML
*/
export const markdownMultilineInlineToHtml = (value) => String(value || '')
.split('\n')
.map((line) => markdownInlineToHtml(line))
.join('<br>')
/**
* 인라인 HTML 노드를 마크다운 문자열로 변환한다.
* @param {Node} node - HTML 노드
* @returns {string} 마크다운 문자열
*/
export 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'
}
if (tagName === 'div' || tagName === 'p' || tagName === 'span') {
return childText
}
return childText
}
/**
* Range에 포함된 HTML 조각을 반환한다.
* @param {Range} range - DOM Range
* @returns {string} HTML
*/
export const getRangeInnerHtml = (range) => {
if (!range) {
return ''
}
const fragment = range.cloneContents()
const container = document.createElement('div')
container.appendChild(fragment)
return container.innerHTML
}
/**
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
* @param {string} html - innerHTML
* @returns {string} 마크다운
*/
export const convertEditableHtmlToMarkdown = (html) => {
if (!html?.trim()) {
return ''
}
const document = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
const parts = []
document.body.childNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE && /** @type {HTMLElement} */ (node).tagName.toLowerCase() === 'br') {
parts.push('\n')
return
}
const converted = convertHtmlInlineNodeToMarkdown(node)
if (converted) {
parts.push(converted)
}
})
return parts.join('').replace(/\u00a0/g, ' ').trimEnd()
}
/**
* 문단 블록 텍스트를 소스 줄 배열로 변환한다.
* @param {string} text - 문단 텍스트
* @returns {string[]} 마크다운 줄
*/
/**
* HTML 블록 노드를 마크다운 문자열로 변환한다.
* @param {Node} node - HTML 노드
* @param {number} listIndex - 순서 목록 번호
* @returns {string} 마크다운 문자열
*/
export 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} 마크다운 문자열
*/
export 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 {string} text - 문단 텍스트
* @returns {string[]} 마크다운 줄
*/
export const paragraphTextToSourceLines = (text) => {
const parts = String(text || '').split('\n')
if (!parts.length) {
return ['']
}
if (parts.length === 1) {
return [parts[0]]
}
return parts.map((part, index) => (index < parts.length - 1 ? `${part} ` : part))
}

130
lib/markdown-live-edit.js Normal file
View File

@@ -0,0 +1,130 @@
/**
* 인용 접두사 제거
* @param {string} value - 원본
* @returns {string} 본문
*/
export const stripQuoteMarker = (value) => String(value ?? '').replace(/^(?:>\s*)+/, '').trim()
/**
* 목록 접두사 제거
* @param {string} value - 원본
* @param {boolean} ordered - 순서 목록 여부
* @returns {string} 본문
*/
export const stripListMarker = (value, ordered = false) => {
const raw = String(value ?? '').trim()
if (ordered) {
return raw.replace(/^\d+\.\s*/, '').trim()
}
return raw.replace(/^[-*+]\s+/, '').trim()
}
/**
* 인용 마커 포함 여부
* @param {string} value - 원본
* @returns {boolean}
*/
export const hasQuoteMarker = (value) => /^\s*>/.test(String(value ?? ''))
/**
* 목록 마커 포함 여부
* @param {string} value - 원본
* @param {boolean} [ordered=false] - 순서 목록 여부
* @returns {boolean}
*/
export const hasListMarker = (value, ordered = false) => {
const raw = String(value ?? '').trim()
if (ordered) {
return /^\d+\.\s*/.test(raw)
}
return /^[-*+]\s/.test(raw)
}
/**
* 순서 목록 마커를 파싱한다.
* @param {string} value - 원본 줄
* @returns {{ number: number, body: string }|null}
*/
export const parseOrderedListMarker = (value) => {
const raw = String(value ?? '').trim()
const match = raw.match(/^(\d+)\.\s*(.*)$/)
if (!match) {
return null
}
return {
number: Number(match[1]),
body: match[2].trim()
}
}
/**
* 순서 목록 다음 줄 마커를 만든다.
* @param {string} line - 현재 줄
* @returns {string} 다음 줄 마커
*/
export const getNextOrderedListLine = (line) => {
const parsed = parseOrderedListMarker(line)
if (!parsed) {
return ''
}
return `${parsed.number + 1}. `
}
/**
* 마커만 있고 본문이 없는 목록 줄인지 확인한다.
* @param {string} line - 마크다운 줄
* @param {boolean} ordered - 순서 목록 여부
* @returns {boolean}
*/
export const isEmptyListMarkerLine = (line, ordered = false) => {
if (!hasListMarker(line, ordered)) {
return false
}
if (ordered) {
const parsed = parseOrderedListMarker(line)
return parsed ? !parsed.body : false
}
return !stripListMarker(line, false).trim()
}
/**
* 마커만 있고 본문이 없는 인용 줄인지 확인한다.
* @param {string} line - 마크다운 줄
* @returns {boolean}
*/
export const isEmptyQuoteMarkerLine = (line) => {
if (!hasQuoteMarker(line)) {
return false
}
return !stripQuoteMarker(line).trim()
}
/**
* 제목 접두사 제거
* @param {string} value - 원본
* @returns {{ level: number, text: string }} 레벨·본문
*/
export const stripHeadingMarker = (value) => {
const raw = String(value ?? '').trim()
const match = raw.match(/^(#{1,6})\s+(.*)$/)
if (!match) {
return { level: 0, text: raw }
}
return {
level: match[1].length,
text: match[2].trim()
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.2.5",
"version": "1.2.8",
"private": true,
"type": "module",
"imports": {