/** @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 ? `![${alt}](${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 }