/** @type {RegExp} 인라인 마크다운 패턴 */ const INLINE_MARKDOWN_RE = /(\$([^$\n]+?)\$|\[([^\]]+)\]\((https?:\/\/[^)\s]+|\/[^)\s]+)\)|\*\*([^*]+)\*\*|`([^`]+)`|\*([^*]+)\*)/g /** * 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} value - 원본 문자열 * @returns {Array<{ type: string, text: string, href?: string }>} 인라인 세그먼트 */ export const parseInlineSegments = (value) => { const source = String(value || '') const segments = [] let lastIndex = 0 INLINE_MARKDOWN_RE.lastIndex = 0 let match = INLINE_MARKDOWN_RE.exec(source) while (match) { if (match.index > lastIndex) { segments.push({ type: 'text', text: source.slice(lastIndex, match.index) }) } if (match[2]) { segments.push(...parseObsidianMathSegments(match[2])) } else if (match[3] && match[4]) { segments.push({ type: 'link', text: match[3], href: match[4] }) } else if (match[5]) { segments.push({ type: 'strong', text: match[5] }) } else if (match[6]) { segments.push({ type: 'code', text: match[6] }) } else if (match[7]) { segments.push({ type: 'em', text: match[7] }) } lastIndex = INLINE_MARKDOWN_RE.lastIndex match = INLINE_MARKDOWN_RE.exec(source) } 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 }>} segments - 세그먼트 * @returns {string} HTML */ export const segmentsToInlineHtml = (segments) => segments.map((segment) => { if (segment.type === 'strong') { return `${escapeHtml(segment.text)}` } if (segment.type === 'em') { return `${escapeHtml(segment.text)}` } if (segment.type === 'subscript') { return `${escapeHtml(segment.text)}` } if (segment.type === 'superscript') { return `${escapeHtml(segment.text)}` } if (segment.type === 'code') { return `${escapeHtml(segment.text)}` } if (segment.type === 'link') { return `${escapeHtml(segment.text)}` } 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 || '') .split('\n') .map((line) => markdownInlineToHtml(line)) .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 === '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' || tagName === 'span') { 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 (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 (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 }