/** @type {string[]} 인라인 색상 프리셋 */
export const INLINE_COLOR_NAMES = ['gray', 'blue', 'green', 'yellow', 'red', 'purple']
/** @type {RegExp} 인라인 색상 시작 패턴 */
const INLINE_COLOR_START_RE = /::(gray|blue|green|yellow|red|purple)\[/g
/** @type {RegExp} 인라인 마크다운 패턴 */
const INLINE_MARKDOWN_RE = /(\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g
/**
* 지원하는 인라인 색상인지 확인한다.
* @param {string|null|undefined} color - 색상 이름
* @returns {boolean} 지원 여부
*/
export const isInlineColorName = (color) => INLINE_COLOR_NAMES.includes(String(color || '').trim().toLowerCase())
/**
* HTML 특수문자 이스케이프
* @param {string} value - 원본 문자열
* @returns {string} 이스케이프된 문자열
*/
export const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
/**
* 위첨자·아래첨자 토큰 본문을 읽는다.
* @param {string} source - 수식 본문
* @param {number} startIndex - 토큰 시작 위치
* @returns {{ text: string, nextIndex: number }}
*/
const readScriptToken = (source, startIndex) => {
const first = source[startIndex]
if (!first) {
return { text: '', nextIndex: startIndex }
}
if (first === '{') {
const closeIndex = source.indexOf('}', startIndex + 1)
if (closeIndex > startIndex) {
return {
text: source.slice(startIndex + 1, closeIndex),
nextIndex: closeIndex + 1
}
}
}
if (/\d/.test(first)) {
let index = startIndex
while (index < source.length && /\d/.test(source[index])) {
index += 1
}
return {
text: source.slice(startIndex, index),
nextIndex: index
}
}
if (/[A-Za-z가-힣]/.test(first)) {
let index = startIndex
while (index < source.length && source[index] !== '_' && source[index] !== '^') {
index += 1
}
return {
text: source.slice(startIndex, index),
nextIndex: index
}
}
return {
text: first,
nextIndex: startIndex + 1
}
}
/**
* Obsidian식 `$H_2O$` 인라인 수식을 세그먼트로 변환한다.
* @param {string} value - 수식 본문
* @returns {Array<{ type: string, text: string }>} 인라인 세그먼트
*/
export const parseObsidianMathSegments = (value) => {
const source = String(value || '')
const segments = []
let textBuffer = ''
let index = 0
const flushText = () => {
if (!textBuffer) {
return
}
segments.push({ type: 'text', text: textBuffer })
textBuffer = ''
}
while (index < source.length) {
const marker = source[index]
if ((marker === '_' || marker === '^') && index < source.length - 1) {
const token = readScriptToken(source, index + 1)
if (token.text) {
flushText()
segments.push({
type: marker === '_' ? 'subscript' : 'superscript',
text: token.text
})
index = token.nextIndex
continue
}
}
textBuffer += marker
index += 1
}
flushText()
return segments
}
/**
* 위첨자·아래첨자를 Obsidian식 `$...$` 토큰으로 직렬화한다.
* @param {'_'|'^'} marker - 첨자 기호
* @param {string} value - 첨자 본문
* @returns {string}
*/
const formatScriptMarkdown = (marker, value) => {
const text = String(value || '')
const body = /^[A-Za-z0-9가-힣]+$/.test(text) ? text : `{${text}}`
return `$${marker}${body}$`
}
/**
* 색상 인라인 마크다운을 만든다.
* @param {string} color - 색상 이름
* @param {string} value - 표시 텍스트
* @returns {string} 색상 인라인 마크다운
*/
export const formatInlineColorMarkdown = (color, value) => {
const normalizedColor = String(color || '').trim().toLowerCase()
const safeColor = isInlineColorName(normalizedColor) ? normalizedColor : 'red'
const text = String(value || '').replaceAll(']', '\\]')
return `::${safeColor}[${text}]`
}
/**
* 색상 토큰 본문을 대괄호 중첩 기준으로 읽는다.
* @param {string} source - 원본 문자열
* @param {number} startIndex - 색상 토큰 시작 위치
* @param {string} color - 색상 이름
* @returns {{ color: string, text: string, nextIndex: number }|null} 색상 토큰
*/
const readInlineColorToken = (source, startIndex, color) => {
const prefix = `::${color}[`
const contentStartIndex = startIndex + prefix.length
let depth = 1
let escaped = false
for (let index = contentStartIndex; index < source.length; index += 1) {
const character = source[index]
if (escaped) {
escaped = false
continue
}
if (character === '\\') {
escaped = true
continue
}
if (character === '[') {
depth += 1
continue
}
if (character !== ']') {
continue
}
depth -= 1
if (depth === 0) {
return {
color,
text: source.slice(contentStartIndex, index).replaceAll('\\]', ']'),
nextIndex: index + 1
}
}
}
return null
}
/**
* 다음 인라인 색상 토큰 후보를 찾는다.
* @param {string} source - 원본 문자열
* @param {number} startIndex - 검색 시작 위치
* @returns {RegExpExecArray|null} 정규식 매치
*/
const findNextInlineColorStart = (source, startIndex) => {
INLINE_COLOR_START_RE.lastIndex = startIndex
return INLINE_COLOR_START_RE.exec(source)
}
/**
* 다음 일반 인라인 토큰 후보를 찾는다.
* @param {string} source - 원본 문자열
* @param {number} startIndex - 검색 시작 위치
* @returns {RegExpExecArray|null} 정규식 매치
*/
const findNextInlineMarkdownToken = (source, startIndex) => {
INLINE_MARKDOWN_RE.lastIndex = startIndex
return INLINE_MARKDOWN_RE.exec(source)
}
/**
* 인라인 마크다운을 표시 세그먼트로 변환한다.
* @param {string} value - 원본 문자열
* @returns {Array<{ type: string, text: string, href?: string, color?: string }>} 인라인 세그먼트
*/
export const parseInlineSegments = (value) => {
const source = String(value || '')
const segments = []
let lastIndex = 0
let searchIndex = 0
let colorMatch = findNextInlineColorStart(source, searchIndex)
let match = findNextInlineMarkdownToken(source, searchIndex)
while (colorMatch || match) {
const useColorMatch = colorMatch && (!match || colorMatch.index <= match.index)
const activeMatch = useColorMatch ? colorMatch : match
if (activeMatch.index > lastIndex) {
segments.push({
type: 'text',
text: source.slice(lastIndex, activeMatch.index)
})
}
if (useColorMatch) {
const colorToken = readInlineColorToken(source, colorMatch.index, colorMatch[1])
if (!colorToken) {
segments.push({
type: 'text',
text: source.slice(colorMatch.index, colorMatch.index + colorMatch[0].length)
})
lastIndex = colorMatch.index + colorMatch[0].length
searchIndex = lastIndex
} else {
segments.push({
type: 'color',
color: colorToken.color,
text: colorToken.text
})
lastIndex = colorToken.nextIndex
searchIndex = colorToken.nextIndex
}
} else if (match[2]) {
segments.push(...parseObsidianMathSegments(match[2]))
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[3] && match[4]) {
segments.push({
type: 'link',
text: match[3],
href: match[4]
})
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[5]) {
segments.push({
type: 'strong',
text: match[5]
})
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[6]) {
segments.push({
type: 'code',
text: match[6]
})
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
} else if (match[7]) {
segments.push({
type: 'em',
text: match[7]
})
lastIndex = INLINE_MARKDOWN_RE.lastIndex
searchIndex = INLINE_MARKDOWN_RE.lastIndex
}
colorMatch = findNextInlineColorStart(source, searchIndex)
match = findNextInlineMarkdownToken(source, searchIndex)
}
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, color?: string }>} segments - 세그먼트
* @returns {string} HTML
*/
export const segmentsToInlineHtml = (segments) => segments.map((segment) => {
if (segment.type === 'strong') {
return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}`
}
if (segment.type === 'em') {
return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}`
}
if (segment.type === 'subscript') {
return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}`
}
if (segment.type === 'superscript') {
return `${segmentsToInlineHtml(parseInlineSegments(segment.text))}`
}
if (segment.type === 'code') {
return `${escapeHtml(segment.text)}`
}
if (segment.type === 'link') {
const nestedHtml = segmentsToInlineHtml(parseInlineSegments(segment.text))
return `${nestedHtml}`
}
if (segment.type === 'color' && isInlineColorName(segment.color)) {
const color = String(segment.color || '').trim().toLowerCase()
const nestedHtml = segmentsToInlineHtml(parseInlineSegments(segment.text))
return `${nestedHtml}`
}
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 || '')
.replace(/
/gi, '\n')
.split('\n')
.map((line) => markdownInlineToHtml(line))
.join('
')
/**
* 여러 줄 마크다운을 라이브 편집용 블록 HTML로 변환한다.
* @param {string} value - 줄바꿈 포함 마크다운
* @returns {string} HTML
*/
export const markdownMultilineBlockToHtml = (value) => {
const lines = String(value || '').replace(/
/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(`
${markdownInlineToHtml(itemMatch[1])}`)
index += 1
}
htmlParts.push(``)
continue
}
if (orderedMatch) {
const items = []
while (index < lines.length) {
const itemMatch = lines[index].match(/^\s*\d+\.\s+(.*)$/)
if (!itemMatch) {
break
}
items.push(`${markdownInlineToHtml(itemMatch[1])}`)
index += 1
}
htmlParts.push(`${items.join('')}
`)
continue
}
htmlParts.push(line ? markdownInlineToHtml(line) : '
')
index += 1
}
return htmlParts.join('
')
}
/**
* 인라인 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 === 'sub') {
return formatScriptMarkdown('_', childText)
}
if (tagName === 'sup') {
return formatScriptMarkdown('^', childText)
}
if (tagName === 'code') {
return `\`${childText}\``
}
if (tagName === 'a') {
const href = element.getAttribute('href')
return href ? `[${childText || href}](${href})` : childText
}
if (tagName === 'span') {
const inlineColor = element.getAttribute('data-inline-color')
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') || ''
return src ? `` : ''
}
if (tagName === 'br') {
return '\n'
}
if (tagName === 'div' || tagName === 'p') {
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
}
/** @type {Set} contenteditable 줄 구분 블록 태그 */
const EDITABLE_BLOCK_TAGS = new Set(['div', 'p'])
/**
* 루트 직계 자식이 줄 구분 블록인지 확인한다.
* @param {HTMLElement} element - 요소
* @param {HTMLElement} root - contenteditable 루트
* @returns {boolean}
*/
const isEditableBlockBreak = (element, root) => {
if (!element || element === root) {
return false
}
return EDITABLE_BLOCK_TAGS.has(element.tagName.toLowerCase())
&& element.parentElement === root
}
/**
* contenteditable 텍스트 단위를 순회한다.
* @param {HTMLElement} root - contenteditable 루트
* @yields {{ kind: 'text'|'break'|'block-break', node: Node|null, length: number }}
*/
function* iterateEditableTextUnits(root) {
/**
* @param {Node} node - 순회 노드
* @param {boolean} parentIsRoot - 루트 직계 여부
* @param {number} indexInParent - 형제 인덱스
* @returns {Generator<{ kind: string, node: Node|null, length: number }>}
*/
const visit = function* (node, parentIsRoot, indexInParent) {
if (node.nodeType === Node.TEXT_NODE) {
yield { kind: 'text', node, length: node.textContent?.length ?? 0 }
return
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return
}
const element = /** @type {HTMLElement} */ (node)
const tag = element.tagName.toLowerCase()
if (tag === 'br') {
yield { kind: 'break', node, length: 1 }
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 }
}
const children = [...element.childNodes]
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
yield* visit(children[childIndex], false, childIndex)
}
return
}
const children = [...element.childNodes]
for (let childIndex = 0; childIndex < children.length; childIndex += 1) {
yield* visit(children[childIndex], parentIsRoot, childIndex)
}
}
const children = [...root.childNodes]
for (let index = 0; index < children.length; index += 1) {
yield* visit(children[index], true, index)
}
}
/**
* contenteditable 루트의 단일 자식 노드를 마크다운 인라인 문자열로 직렬화한다.
* @param {Node} node - 자식 노드
* @returns {string} 마크다운 조각
*/
const readEditableChildNodeToMarkdown = (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()
if (tagName === 'br') {
return '\n'
}
return convertHtmlInlineNodeToMarkdown(element)
}
/**
* contenteditable 루트에서 텍스트를 읽는다.
* @param {HTMLElement} root - contenteditable 루트
* @param {{ trimEnd?: boolean }} [options] - 읽기 옵션
* @returns {string} 마크다운 인라인 텍스트
*/
export const readEditableTextFromElement = (root, options = {}) => {
if (!root) {
return ''
}
const parts = []
for (let index = 0; index < root.childNodes.length; index += 1) {
const node = root.childNodes[index]
if (node.nodeType === Node.ELEMENT_NODE && isEditableBlockBreak(/** @type {HTMLElement} */ (node), root) && index > 0) {
parts.push('\n')
}
parts.push(readEditableChildNodeToMarkdown(node))
}
const value = parts.join('').replace(/\u00a0/g, ' ')
return options.trimEnd === false ? value : value.trimEnd()
}
/**
* contenteditable 루트에서 커서 오프셋을 계산한다.
* @param {HTMLElement} root - contenteditable 루트
* @param {Range} range - 선택 범위
* @returns {number} 텍스트 오프셋
*/
export const getEditableCaretOffset = (root, range) => {
if (!root || !range) {
return 0
}
if (range.startContainer === root) {
let offset = 0
const children = [...root.childNodes]
const measureRoot = document.createElement('div')
for (let index = 0; index < Math.min(range.startOffset, children.length); index += 1) {
measureRoot.appendChild(children[index].cloneNode(true))
}
for (const unit of iterateEditableTextUnits(/** @type {HTMLElement} */ (measureRoot))) {
offset += unit.length
}
return offset
}
let offset = 0
let found = false
for (const unit of iterateEditableTextUnits(root)) {
if (found) {
break
}
if (unit.kind === 'text' && unit.node === range.startContainer) {
offset += range.startOffset
found = true
break
}
if (unit.node === range.startContainer) {
found = true
break
}
offset += unit.length
}
return offset
}
/**
* contenteditable 루트에 커서를 텍스트 오프셋으로 둔다.
* @param {HTMLElement} root - contenteditable 루트
* @param {number} targetOffset - 텍스트 오프셋
* @returns {void}
*/
export const setEditableCaretOffset = (root, targetOffset) => {
if (!root || !import.meta.client) {
return
}
const safeOffset = Math.max(0, targetOffset)
let walked = 0
let placed = false
for (const unit of iterateEditableTextUnits(root)) {
if (placed) {
break
}
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
if (walked + unit.length >= safeOffset) {
const range = document.createRange()
const charOffset = Math.min(safeOffset - walked, unit.length)
range.setStart(unit.node, charOffset)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
placed = true
break
}
walked += unit.length
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)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
placed = true
break
}
walked += unit.length
}
if (!placed) {
const range = document.createRange()
range.selectNodeContents(root)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
}
/**
* contenteditable 루트의 텍스트 오프셋에 해당하는 DOM 위치를 반환한다.
* @param {HTMLElement} root - contenteditable 루트
* @param {number} targetOffset - 텍스트 오프셋
* @returns {{ node: Node, offset: number }|null} DOM 위치
*/
export const getEditableDomPointAtOffset = (root, targetOffset) => {
if (!root || !import.meta.client) {
return null
}
const safeOffset = Math.max(0, targetOffset)
let walked = 0
for (const unit of iterateEditableTextUnits(root)) {
if (unit.kind === 'text' && unit.node?.nodeType === Node.TEXT_NODE) {
if (walked + unit.length >= safeOffset) {
return {
node: unit.node,
offset: Math.min(safeOffset - walked, unit.length)
}
}
walked += unit.length
continue
}
if (walked + unit.length >= safeOffset) {
return {
node: root,
offset: root.childNodes.length
}
}
walked += unit.length
}
return {
node: root,
offset: root.childNodes.length
}
}
/**
* contenteditable 내부 HTML을 인라인 마크다운으로 변환한다.
* @param {string} html - innerHTML
* @returns {string} 마크다운
*/
export const convertEditableHtmlToMarkdown = (html) => {
if (!html?.trim()) {
return ''
}
const document = new DOMParser().parseFromString(`${html}`, 'text/html')
return readEditableTextFromElement(/** @type {HTMLElement} */ (document.body))
}
/**
* 문단 블록 텍스트를 소스 줄 배열로 변환한다.
* @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').map((part) => part.trimEnd())
if (!parts.length) {
return ['']
}
return parts
}