import type { PlaitElement } from "@plait/core"; export type NoteText = { text: string; bold?: boolean; italic?: boolean; underline?: boolean; strikethrough?: boolean; href?: string; properties?: Record; }; export type NoteParagraph = { type: "paragraph"; list?: "bullet" | "number"; indent?: number; properties?: Record; children: NoteText[]; }; export type NoteDocument = NoteParagraph[]; const record = (v: unknown): Record => v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : {}; export const safeNoteLink = (value: string) => { try { const url = new URL(value); return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol) ? url.href : null; } catch { return null; } }; function parseNoteHTML(html: string): NoteDocument | null { if (typeof DOMParser === "undefined") return null; const dom = new DOMParser().parseFromString(html, "text/html"); const ignored = new Set([ "SCRIPT", "STYLE", "IFRAME", "OBJECT", "SVG", "FORM", "INPUT", ]); const inline = ( node: Node, inherited: Partial = {} ): NoteText[] => { if (node.nodeType === 3) return [{ ...inherited, text: node.textContent || "" }]; if (!(node instanceof Element) || ignored.has(node.tagName)) return []; if (node.tagName === "BR") return [{ ...inherited, text: "\n" }]; const style = (node as HTMLElement).style; const marks: Partial = { ...inherited }; if ( ["B", "STRONG"].includes(node.tagName) || style.fontWeight === "bold" || Number(style.fontWeight) >= 600 ) marks.bold = true; if (["I", "EM"].includes(node.tagName) || style.fontStyle === "italic") marks.italic = true; if (node.tagName === "U" || style.textDecoration.includes("underline")) marks.underline = true; if ( ["S", "DEL", "STRIKE"].includes(node.tagName) || style.textDecoration.includes("line-through") ) marks.strikethrough = true; const href = safeNoteLink(node.getAttribute("href") || ""); if (href) marks.href = href; return Array.from(node.childNodes).flatMap((child) => inline(child, marks)); }; const result: NoteDocument = []; const visit = (node: Node, list?: "bullet" | "number") => { if (node.nodeType === 3) { if (node.textContent?.trim()) result.push({ type: "paragraph", children: [{ text: node.textContent }], }); return; } if (!(node instanceof Element) || ignored.has(node.tagName)) return; if (["OL", "UL"].includes(node.tagName)) { Array.from(node.children).forEach((child) => visit(child, node.tagName === "OL" ? "number" : "bullet") ); return; } if ( [ "P", "LI", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "BLOCKQUOTE", "PRE", ].includes(node.tagName) ) { const blocks = Array.from(node.children).some((child) => ["P", "LI", "DIV", "OL", "UL"].includes(child.tagName) ); if (blocks && node.tagName !== "LI") { Array.from(node.childNodes).forEach((child) => visit(child, list)); return; } const leaves = Array.from(node.childNodes) .filter( (child) => !(child instanceof Element && ["OL", "UL"].includes(child.tagName)) ) .flatMap((child) => inline(child)); result.push({ type: "paragraph", ...(list ? { list } : {}), indent: Number.parseFloat((node as HTMLElement).style.marginLeft) || 0, children: leaves.length ? leaves : [{ text: "" }], }); Array.from(node.children) .filter((child) => ["OL", "UL"].includes(child.tagName)) .forEach((child) => visit(child)); return; } const leaves = inline(node); if (leaves.length) result.push({ type: "paragraph", children: leaves }); }; Array.from(dom.body.childNodes).forEach((node) => visit(node)); return result.length ? result : null; } const escapeHTML = (value: string) => value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); function noteDocumentHTML(doc: NoteDocument) { let html = "", list: "bullet" | "number" | undefined; for (const paragraph of doc) { if (list !== paragraph.list) { if (list) html += list === "number" ? "" : ""; list = paragraph.list; if (list) html += list === "number" ? "
    " : "
      "; } const tag = list ? "li" : "p"; let content = paragraph.children .map((t) => { let text = escapeHTML(t.text).replaceAll("\n", "
      "); if (t.bold) text = `${text}`; if (t.italic) text = `${text}`; if (t.underline) text = `${text}`; if (t.strikethrough) text = `${text}`; const href = safeNoteLink(t.href || ""); return href ? `${text}` : text; }) .join(""); if (!content) content = "
      "; html += `<${tag}${paragraph.indent ? ` style="margin-left:${Math.max(0, Math.min(120, paragraph.indent))}px"` : ""}>${content}`; } if (list) html += list === "number" ? "
" : ""; return html; } export function getNoteDocument(element: PlaitElement | null): NoteDocument { const notes = record(record(element?.xmind).notes); const realHTML = record(notes.realHTML).content; if (typeof realHTML === "string") { const parsed = parseNoteHTML(realHTML); if (parsed) return parsed; } const paragraphs = record(record(notes.html).content).paragraphs; if (Array.isArray(paragraphs) && paragraphs.length) { const readSpans = ( spans: unknown, inherited: Record = {}, href = "" ): NoteText[] => Array.isArray(spans) ? spans.flatMap((span) => { const item = record(span), props = { ...inherited, ...record(record(item.style).properties), }; const link = typeof item.href === "string" ? item.href : href; if (Array.isArray(item.spans)) return readSpans(item.spans, props, link); if (typeof item.text !== "string") return []; return [ { text: item.text, properties: props, bold: ["bold", "700", "600", "800", "900"].includes( String(props["fo:font-weight"] || props["font-weight"] || "") ), italic: (props["fo:font-style"] || props["font-style"]) === "italic", underline: String( props["fo:text-decoration"] || props["text-decoration"] || "" ).includes("underline"), strikethrough: String( props["fo:text-decoration"] || props["text-decoration"] || "" ).includes("line-through"), ...(safeNoteLink(link) ? { href: safeNoteLink(link)! } : {}), }, ]; }) : []; return paragraphs.map((p) => { const item = record(p), props = record(record(item.style).properties); const bullet = String(props["fo:text-bullet"] || ""); const children = readSpans(item.spans); return { type: "paragraph", properties: props, ...(bullet === "number" || bullet === "decimal" ? { list: "number" as const } : bullet && bullet !== "none" ? { list: "bullet" as const } : {}), indent: Number(props["fo:margin-left"]) || 0, children: children.length ? children : [{ text: "" }], }; }); } const plain = typeof record(element?.xmind).notes === "string" ? String(record(element?.xmind).notes) : String(record(notes.plain).content || ""); return plain .split("\n") .map((text) => ({ type: "paragraph", children: [{ text }] })); } export const noteDocumentText = (doc: NoteDocument) => doc.map((p) => p.children.map((t) => t.text).join("")).join("\n"); export function serializeNoteDocument(doc: NoteDocument) { const plain = noteDocumentText(doc); if (!plain.trim()) return undefined; return { plain: { content: plain }, realHTML: { content: noteDocumentHTML(doc) }, html: { content: { paragraphs: doc.map((p) => ({ style: { type: "paragraph", properties: { ...p.properties, "fo:text-bullet": p.list === "number" ? "number" : p.list === "bullet" ? "bullet" : "none", "fo:margin-left": String(p.indent || 0), }, }, spans: p.children.map((t) => ({ text: t.text, ...(safeNoteLink(t.href || "") ? { href: safeNoteLink(t.href!) } : {}), style: { type: "text", properties: { ...t.properties, "fo:font-weight": t.bold ? "bold" : "normal", "fo:font-style": t.italic ? "italic" : "normal", "fo:text-decoration": [ t.underline ? "underline" : "", t.strikethrough ? "line-through" : "", ] .filter(Boolean) .join(" ") || "none", }, }, })), })), }, }, }; }