import type { MemoAnnotation } from "@mosaiciq/contracts/rpc"; import { cx } from "./cn"; export function markdownToEditableHtml(content: string, annotations: MemoAnnotation[] = []) { if (!content) return ""; const lines = content.split(/\r?\n/); const blocks: string[] = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!line.trim()) { blocks.push("


"); continue; } const unorderedItems: string[] = []; while (index < lines.length) { const match = lines[index].match(/^\s*[-*]\s+(.+)$/); if (!match) break; unorderedItems.push(match[1]); index += 1; } if (unorderedItems.length) { index -= 1; blocks.push(``); continue; } const orderedItems: string[] = []; while (index < lines.length) { const match = lines[index].match(/^\s*\d+\.\s+(.+)$/); if (!match) break; orderedItems.push(match[1]); index += 1; } if (orderedItems.length) { index -= 1; blocks.push(`
    ${orderedItems.map((item) => `
  1. ${renderEditableInlineMarkdown(item, annotations)}
  2. `).join("")}
`); continue; } const heading = line.match(/^(#{1,3})\s+(.+)$/); if (heading) { const level = Math.min(heading[1].length + 3, 6); blocks.push(`${renderEditableInlineMarkdown(heading[2], annotations)}`); continue; } const quote = line.match(/^>\s+(.+)$/); if (quote) { blocks.push(`
${renderEditableInlineMarkdown(quote[1], annotations)}
`); continue; } blocks.push(`

${renderEditableInlineMarkdown(line, annotations)}

`); } return blocks.join(""); } function renderEditableInlineMarkdown(value: string, annotations: MemoAnnotation[] = []) { const pattern = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\)|\*[^*]+\*)/g; let cursor = 0; let html = ""; let match: RegExpExecArray | null; while ((match = pattern.exec(value)) !== null) { if (match.index > cursor) html += renderAnnotatedPlainText(value.slice(cursor, match.index), annotations); const token = match[0]; if (token.startsWith("**")) { html += `${escapeHtml(token.slice(2, -2))}`; } else if (token.startsWith("*")) { html += `${escapeHtml(token.slice(1, -1))}`; } else if (token.startsWith("`")) { html += `${escapeHtml(token.slice(1, -1))}`; } else { const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); html += link ? `${escapeHtml(link[1])}` : escapeHtml(token); } cursor = match.index + token.length; } if (cursor < value.length) html += renderAnnotatedPlainText(value.slice(cursor), annotations); return html; } function renderAnnotatedPlainText(value: string, annotations: MemoAnnotation[]) { const candidates = annotations.filter((annotation) => annotation.selectedText); let cursor = 0; let html = ""; while (cursor < value.length) { const match = findNextAnnotationMatch(value, candidates, cursor); if (!match) { html += escapeHtml(value.slice(cursor)); break; } if (match.index > cursor) html += escapeHtml(value.slice(cursor, match.index)); const matchingAnnotations = candidates.filter((annotation) => annotation.selectedText === match.text); html += `${escapeHtml(match.text)}`; cursor = match.index + match.text.length; } return html; } function findNextAnnotationMatch(value: string, annotations: MemoAnnotation[], offset: number) { let bestMatch: { index: number; text: string } | null = null; for (const annotation of annotations) { const index = value.indexOf(annotation.selectedText, offset); if (index === -1) continue; if ( !bestMatch || index < bestMatch.index || (index === bestMatch.index && annotation.selectedText.length > bestMatch.text.length) ) { bestMatch = { index, text: annotation.selectedText }; } } return bestMatch; } function annotationClassName(annotations: MemoAnnotation[]) { const kinds = new Set(annotations.map((annotation) => annotation.kind)); return cx( "px-0.5", kinds.has("highlight") && "bg-[oklch(92%_0.08_95)]", kinds.has("comment") && "bg-[oklch(92%_0.06_80)] underline decoration-[var(--accent)] decoration-2 underline-offset-2", kinds.has("strike") && "line-through decoration-[var(--red)] decoration-2" ); } export function getAnnotationSignature(annotations: MemoAnnotation[]) { return annotations .map((annotation) => `${annotation.id}:${annotation.kind}:${annotation.status}:${annotation.selectedText}`) .join("|"); } export function getSelectionWithin(root: HTMLElement | null) { if (!root) return ""; const selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return ""; const range = selection.getRangeAt(0); if (!root.contains(range.commonAncestorContainer)) return ""; return selection.toString().trim(); } export function editableHtmlToMarkdown(root: HTMLElement) { const blocks = Array.from(root.childNodes).flatMap((node) => serializeBlockNode(node)); return joinMarkdownBlocks(blocks); } function joinMarkdownBlocks(blocks: string[]) { if (blocks.every((block) => !block.trim())) return ""; return blocks.reduce((markdown, block, index) => { if (index === 0) return block; const previous = markdown.split("\n").at(-1) ?? ""; const separator = !previous.trim() || !block.trim() || shouldKeepAdjacent(previous, block) ? "\n" : "\n"; return `${markdown}${separator}${block}`; }, ""); } function shouldKeepAdjacent(previous: string, next: string) { return ( (/^[-*]\s+\S/.test(previous) && /^[-*]\s+\S/.test(next)) || (/^\d+\.\s+\S/.test(previous) && /^\d+\.\s+\S/.test(next)) || (/^>\s+\S/.test(previous) && /^>\s+\S/.test(next)) ); } function serializeBlockNode(node: ChildNode): string[] { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent?.trim(); return text ? [text] : []; } if (!(node instanceof HTMLElement)) return []; const tagName = node.tagName.toLowerCase(); if (tagName === "ul") { return Array.from(node.children) .filter((child) => child.tagName.toLowerCase() === "li") .map((child) => `- ${serializeInlineNode(child).trim()}`) .filter((line) => line.length > 2); } if (tagName === "ol") { return Array.from(node.children) .filter((child) => child.tagName.toLowerCase() === "li") .map((child, index) => `${index + 1}. ${serializeInlineNode(child).trim()}`) .filter((line) => /\d+\.\s+\S/.test(line)); } if (/^h[1-6]$/.test(tagName)) { const level = Math.min(Math.max(Number(tagName.slice(1)) - 3, 1), 3); const text = serializeInlineNode(node).trim(); return text ? [`${"#".repeat(level)} ${text}`] : []; } if (tagName === "blockquote") { const text = serializeInlineNode(node).trim(); return text ? text.split(/\r?\n/).map((line) => `> ${line}`) : []; } if (tagName === "div" || tagName === "p") { const text = serializeInlineNode(node).trim(); return text ? [text] : [""]; } const text = serializeInlineNode(node).trim(); return text ? [text] : []; } function serializeInlineNode(node: ChildNode): string { if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ""; if (!(node instanceof HTMLElement)) return ""; const tagName = node.tagName.toLowerCase(); if (tagName === "br") return "\n"; const text = Array.from(node.childNodes).map((child) => serializeInlineNode(child)).join(""); if (!text) return ""; if (tagName === "strong" || tagName === "b") return `**${text}**`; if (tagName === "em" || tagName === "i") return `*${text}*`; if (tagName === "code") return `\`${text.replace(/`/g, "")}\``; if (tagName === "a") { const href = node.getAttribute("href"); return href ? `[${text}](${href})` : text; } if (tagName === "li") return text.replace(/\n+/g, " ").trim(); if (tagName === "div" || tagName === "p") return text; return node.textContent ?? ""; } export function normalizeEditableMarkdown(root: HTMLElement): string { const caretOffset = getCaretTextOffset(root); const markdown = editableHtmlToMarkdown(root); const html = markdownToEditableHtml(markdown); if (root.innerHTML !== html) { root.innerHTML = html; if (caretOffset !== null) restoreCaretTextOffset(root, caretOffset); } return markdown; } export function getCaretTextOffset(root: HTMLElement): number | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); if (!root.contains(range.startContainer)) return null; const prefixRange = document.createRange(); prefixRange.selectNodeContents(root); prefixRange.setEnd(range.startContainer, range.startOffset); return prefixRange.toString().length; } export function restoreCaretTextOffset(root: HTMLElement, offset: number): void { const selection = window.getSelection(); if (!selection) return; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); let remaining = offset; let node = walker.nextNode(); let lastTextNode: Text | null = null; while (node) { const textNode = node as Text; lastTextNode = textNode; const length = textNode.textContent?.length ?? 0; if (remaining <= length) { const range = document.createRange(); range.setStart(textNode, remaining); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); return; } remaining -= length; node = walker.nextNode(); } const range = document.createRange(); if (lastTextNode) { range.setStart(lastTextNode, lastTextNode.textContent?.length ?? 0); } else { range.selectNodeContents(root); } range.collapse(false); selection.removeAllRanges(); selection.addRange(range); } export function insertPlainText(text: string) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return; selection.deleteFromDocument(); const range = selection.getRangeAt(0); const textNode = document.createTextNode(text); range.insertNode(textNode); range.setStartAfter(textNode); range.collapse(true); selection.removeAllRanges(); selection.addRange(range); } export function insertParagraphBreak() { document.execCommand("insertParagraph"); } function escapeHtml(value: string) { return value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function escapeAttribute(value: string) { return escapeHtml(value).replace(/'/g, "'"); }