v1.2.8: 라이브 모드 인라인 편집 및 목록·인용 동작 개선
관리자 미리보기에서 문단·목록·인용을 contenteditable로 편집하고, Cmd+E 전환·사용자 지정 순서 번호·줄 삭제·화살표 줄 이동을 지원한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 ? `` : ''
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
661
components/content/ContentMarkdownEditableInline.vue
Normal file
661
components/content/ContentMarkdownEditableInline.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -480,6 +480,7 @@ components/content/
|
||||
- `Cmd/Ctrl+E`는 작성 모드와 미리보기 모드를 전환하며, 미리보기에서 작성 모드로 돌아올 때 기존 커서 위치와 textarea 스크롤을 복원한다.
|
||||
- 관리자 미리보기 패널은 공개 렌더러를 쓰되 밝은 관리자 배경 기준의 본문 색상 변수를 별도로 지정한다.
|
||||
- 관리자 미리보기에서 `ContentMarkdownRenderer`에 `interactive`를 켠다. 갤러리 이미지는 드래그로 순서를 바꿀 수 있으며, 드래그 중 다른 셀 위에 올리면 해당 셀에 주황 테두리와 「여기로 이동」 표시로 드롭 위치를 보여 준 뒤 `gallery-reorder`로 마크다운을 갱신한다.
|
||||
- 관리자 **라이브 모드**(미리보기) 인라인 편집: 문단·빈 줄·제목·인용·목록 항목을 렌더 스타일 그대로 contenteditable로 수정한다. **Enter**는 다음 문단(마크다운 빈 줄), **Shift+Enter**만 같은 문단 내 줄바꿈. 본문 하단 클릭으로 새 문단을 추가한다. 변경은 `block-content-change`·`append-paragraph`로 소스에 반영되며 소스 모드 줄 번호와 일치한다.
|
||||
- 이미지 파일을 붙여넣거나 드롭하면 관리자 업로드 API로 저장한 뒤 현재 커서 위치에 이미지 또는 갤러리 마크다운을 삽입한다.
|
||||
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 즉시 삽입한다.
|
||||
- 미디어 라이브러리에서 단일 이미지를 선택하면 `` 형식으로 삽입한다.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 1차 관리자 개발
|
||||
|
||||
- [ ] Markdown-first 에디터 3차 개선: 옵시디언식 Live Preview(마크다운 토큰 숨김/백스페이스 복원), 표준 마크다운 파서 도입 검토, HTML 붙여넣기 변환 범위 확대
|
||||
- [ ] Markdown-first 에디터 3차 개선: 미리보기 인라인 편집 확대(코드 블록·콜아웃·이미지 캡션·새 블록 추가), 옵시디언식 토큰 숨김 Live Preview, 표준 마크다운 파서 도입 검토
|
||||
|
||||
## 2차 관리자 개발
|
||||
|
||||
|
||||
@@ -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
311
lib/markdown-inline.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
/**
|
||||
* 인라인 마크다운을 표시 세그먼트로 변환한다.
|
||||
* @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 ? `` : ''
|
||||
}
|
||||
|
||||
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
130
lib/markdown-live-edit.js
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
Reference in New Issue
Block a user