v1.2.8: 라이브 모드 인라인 편집 및 목록·인용 동작 개선
관리자 미리보기에서 문단·목록·인용을 contenteditable로 편집하고, Cmd+E 전환·사용자 지정 순서 번호·줄 삭제·화살표 줄 이동을 지원한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user