ai-chatbot/lib/flow-board/topic-notes.ts
134 23ced0b760 feat(flow-board): add rich notes, Gantt tasks and PNG export
Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
2026-10-06 17:38:14 +08:00

291 lines
9.5 KiB
TypeScript

import type { PlaitElement } from "@plait/core";
export type NoteText = {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
properties?: Record<string, unknown>;
};
export type NoteParagraph = {
type: "paragraph";
list?: "bullet" | "number";
indent?: number;
properties?: Record<string, unknown>;
children: NoteText[];
};
export type NoteDocument = NoteParagraph[];
const record = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
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> = {}
): 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<NoteText> = { ...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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
function noteDocumentHTML(doc: NoteDocument) {
let html = "",
list: "bullet" | "number" | undefined;
for (const paragraph of doc) {
if (list !== paragraph.list) {
if (list) html += list === "number" ? "</ol>" : "</ul>";
list = paragraph.list;
if (list) html += list === "number" ? "<ol>" : "<ul>";
}
const tag = list ? "li" : "p";
let content = paragraph.children
.map((t) => {
let text = escapeHTML(t.text).replaceAll("\n", "<br>");
if (t.bold) text = `<strong>${text}</strong>`;
if (t.italic) text = `<em>${text}</em>`;
if (t.underline) text = `<u>${text}</u>`;
if (t.strikethrough) text = `<s>${text}</s>`;
const href = safeNoteLink(t.href || "");
return href ? `<a href="${escapeHTML(href)}">${text}</a>` : text;
})
.join("");
if (!content) content = "<br>";
html += `<${tag}${paragraph.indent ? ` style="margin-left:${Math.max(0, Math.min(120, paragraph.indent))}px"` : ""}>${content}</${tag}>`;
}
if (list) html += list === "number" ? "</ol>" : "</ul>";
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<string, unknown> = {},
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",
},
},
})),
})),
},
},
};
}