라이브 작성 블록 렌더링과 발행 시각 보정

This commit is contained in:
2026-07-02 16:01:45 +09:00
parent 0576069427
commit 7f20949669
15 changed files with 222 additions and 20 deletions

View File

@@ -373,6 +373,64 @@ export const markdownMultilineInlineToHtml = (value) => String(value || '')
.map((line) => markdownInlineToHtml(line))
.join('<br>')
/**
* 여러 줄 마크다운을 라이브 편집용 블록 HTML로 변환한다.
* @param {string} value - 줄바꿈 포함 마크다운
* @returns {string} HTML
*/
export const markdownMultilineBlockToHtml = (value) => {
const lines = String(value || '').replace(/<br\s*\/?>/gi, '\n').split('\n')
const htmlParts = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const bulletMatch = line.match(/^\s*-\s+(.*)$/)
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/)
if (bulletMatch) {
const items = []
while (index < lines.length) {
const itemMatch = lines[index].match(/^\s*-\s+(.*)$/)
if (!itemMatch) {
break
}
items.push(`<li>${markdownInlineToHtml(itemMatch[1])}</li>`)
index += 1
}
htmlParts.push(`<ul>${items.join('')}</ul>`)
continue
}
if (orderedMatch) {
const items = []
while (index < lines.length) {
const itemMatch = lines[index].match(/^\s*\d+\.\s+(.*)$/)
if (!itemMatch) {
break
}
items.push(`<li>${markdownInlineToHtml(itemMatch[1])}</li>`)
index += 1
}
htmlParts.push(`<ol>${items.join('')}</ol>`)
continue
}
htmlParts.push(line ? markdownInlineToHtml(line) : '<br>')
index += 1
}
return htmlParts.join('<br>')
}
/**
* 인라인 HTML 노드를 마크다운 문자열로 변환한다.
* @param {Node} node - HTML 노드
@@ -421,6 +479,24 @@ export const convertHtmlInlineNodeToMarkdown = (node) => {
return isInlineColorName(inlineColor) ? formatInlineColorMarkdown(inlineColor, childText) : childText
}
if (tagName === 'ul') {
return Array.from(element.children)
.filter((child) => child.tagName.toLowerCase() === 'li')
.map((child) => `- ${Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('')}`)
.join('\n')
}
if (tagName === 'ol') {
return Array.from(element.children)
.filter((child) => child.tagName.toLowerCase() === 'li')
.map((child, index) => `${index + 1}. ${Array.from(child.childNodes).map(convertHtmlInlineNodeToMarkdown).join('')}`)
.join('\n')
}
if (tagName === 'li') {
return childText
}
if (tagName === 'img') {
const src = element.getAttribute('src')
const alt = element.getAttribute('alt') || ''
@@ -503,6 +579,33 @@ function* iterateEditableTextUnits(root) {
return
}
if (tag === 'ul' || tag === 'ol') {
if (parentIsRoot && indexInParent > 0) {
yield { kind: 'block-break', node: null, length: 1 }
}
const items = [...element.children].filter((child) => child.tagName.toLowerCase() === 'li')
for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
if (itemIndex > 0) {
yield { kind: 'block-break', node: null, length: 1 }
}
const marker = tag === 'ol' ? `${itemIndex + 1}. ` : '- '
const item = items[itemIndex]
yield { kind: 'marker', node: item, length: marker.length }
const children = [...item.childNodes]
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
yield* visit(children[childIndex], false, childIndex)
}
}
return
}
if (isEditableBlockBreak(element, root)) {
if (parentIsRoot && indexInParent > 0) {
yield { kind: 'block-break', node: null, length: 1 }
@@ -671,6 +774,27 @@ export const setEditableCaretOffset = (root, targetOffset) => {
continue
}
if (unit.kind === 'marker' && unit.node?.nodeType === Node.ELEMENT_NODE) {
if (walked + unit.length >= safeOffset) {
const textWalker = document.createTreeWalker(unit.node, NodeFilter.SHOW_TEXT)
const firstText = textWalker.nextNode()
if (firstText) {
const range = document.createRange()
range.setStart(firstText, 0)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
placed = true
break
}
}
walked += unit.length
continue
}
if (walked + unit.length >= safeOffset) {
const range = document.createRange()
range.selectNodeContents(root)