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">
|
||||
|
||||
Reference in New Issue
Block a user