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.
291 lines
9.5 KiB
TypeScript
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("&", "&")
|
|
.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" ? "</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",
|
|
},
|
|
},
|
|
})),
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
}
|