ai-chatbot/lib/flow-board/xmind-export.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

361 lines
13 KiB
TypeScript

import { getXmindMarker } from "./xmind-markers";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
getTopicTitle,
} from "./topic-details";
import { isMindAnnotation } from "./mind-annotations";
import {
SPECIAL_MIND_LAYOUTS,
isSpecialMindLayout,
} from "./mind-special-layout";
type Data = Record<string, unknown>;
type Sheet = { id: string; title: string; children: PlaitElement[] };
export type XmindExportSnapshot = {
activeSheetId?: string;
children: PlaitElement[];
sheets?: Sheet[];
};
const record = (value: unknown): Data =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Data)
: {};
const array = (value: unknown): Data[] =>
Array.isArray(value) ? value.map(record) : [];
const id = () => crypto.randomUUID();
const layouts: Record<string, string> = {
standard: "org.xmind.ui.map.unbalanced",
right: "org.xmind.ui.logic.right",
left: "org.xmind.ui.logic.left",
downward: "org.xmind.ui.org-chart.down",
upward: "org.xmind.ui.org-chart.up",
"right-bottom-indented": "org.xmind.ui.tree.right",
"left-bottom-indented": "org.xmind.ui.tree.left",
};
const summary = (element: PlaitElement) =>
Number.isInteger(element.start) && Number.isInteger(element.end);
function textOf(value: unknown): string {
const item = record(value);
return typeof item.text === "string"
? item.text
: array(item.children).map(textOf).join("");
}
/** Modern XMind ZIP model: https://github.com/xmindltd/xmind-model/tree/master/schemas */
export async function exportXmind(
snapshot: XmindExportSnapshot,
title: string
) {
const { default: JSZip } = await import("jszip");
const zip = new JSZip();
const warnings = new Set<string>();
const entries: Record<string, object> = {
"content.json": {},
"metadata.json": {},
};
let resourceNumber = 0;
const embed = async (url: string, name: string) => {
if (!/^(data:|https?:)/i.test(url))
throw new Error(`无法导出资源:${name}`);
const response = await fetch(url);
if (!response.ok)
throw new Error(`无法读取资源:${name},请确认图片仍可访问`);
const bytes = new Uint8Array(await response.arrayBuffer());
const safeName =
name.replace(/[\\/:*?"<>|\u0000-\u001f]/g, "_").slice(-120) || "resource";
const path = `resources/${++resourceNumber}/${safeName}`;
zip.file(path, bytes);
entries[path] = {};
return `xap:${path}`;
};
const sourceSheets = snapshot.sheets?.length
? snapshot.sheets
: [{ id: "sheet-1", title, children: snapshot.children }];
const sheets = [];
for (const source of sourceSheets) {
const elements =
source.id === (snapshot.activeSheetId || sourceSheets[0].id)
? snapshot.children
: source.children;
const topics = getTopicEntries(elements);
const ids = new Map(topics.map(({ element }) => [element.id, id()]));
const converted = new Map<string, Data>();
const parents = new Map<string, PlaitElement>();
const build = async (element: PlaitElement): Promise<Data> => {
const data = record(element.data);
const details = getTopicDetails(element);
const metadata = record(element.xmind);
const topic: Data = {
id: ids.get(element.id),
class: "topic",
title: getTopicTitle(element),
};
converted.set(element.id, topic);
if (element.isCollapsed) topic.branch = "folded";
if (element.layout) {
topic.structureClass = layouts[String(element.layout)] || layouts.right;
if (!layouts[String(element.layout)])
warnings.add("部分布局已转换为向右逻辑图");
}
if (isSpecialMindLayout(element.specialLayout))
topic.structureClass =
SPECIAL_MIND_LAYOUTS[element.specialLayout].structure;
if (element.specialLayout === "spreadsheet") {
const configured = Array.isArray(element.spreadsheetColumns)
? element.spreadsheetColumns.filter(
(column): column is string =>
typeof column === "string" && Boolean(column.trim())
)
: [];
const derived = (element.children || []).flatMap(
(child) => getTopicDetails(child).labels
);
const columns = [
...new Set(configured.length ? configured : derived),
];
if (!columns.length) columns.push("列 1", "列 2", "列 3");
topic.extensions = [
{
provider: "org.xmind.ui.spreadsheet",
content: [
{
name: "columns",
content: columns.map((column) => ({
name: "column",
content: column,
})),
},
],
},
];
}
if (
element.layout === "standard" &&
typeof element.rightNodeCount === "number"
)
topic.extensions = [
{
provider: topic.structureClass,
content: [
{ name: "right-number", content: String(element.rightNodeCount) },
],
},
];
// Merge native task extensions with structure extensions; remap every dependency to exported topic IDs.
const nativeExtensions = array(metadata.extensions).filter(ext => !["org.xmind.ui.spreadsheet", ...Object.values(layouts)].includes(String(ext.provider))).map(ext => {
if (ext.provider !== "org.xmind.ui.task") return ext;
const content = record(ext.content);
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: array(content.dependencies).flatMap(dep => {
const target = ids.get(String(dep.id));
if (!target) {warnings.add("指向缺失主题的任务依赖未导出"); return [];}
return [{...dep, id: target}];
})} : {})}};
});
if (nativeExtensions.length) topic.extensions = [...array(topic.extensions), ...nativeExtensions];
const leaves = array(record(data.topic).children);
const leaf = leaves[0] || {};
if (leaves.length > 1) warnings.add("主题内混合文字格式已按首段样式导出");
const properties: Record<string, string> = {};
const copy = (key: string, value: unknown) => {
if (typeof value === "string" || typeof value === "number")
properties[key] = String(value);
};
copy("svg:fill", element.fill);
if (element.fill === "transparent") properties["fill-pattern"] = "none";
copy("border-line-color", element.strokeColor);
copy("border-line-width", element.strokeWidth);
copy("line-color", element.branchColor);
copy("line-width", element.branchWidth);
if (element.branchShape)
properties["line-class"] =
element.branchShape === "polyline"
? "org.xmind.branchConnection.elbow"
: "org.xmind.branchConnection.curve";
if (element.shape)
properties["shape-class"] =
element.shape === "underline"
? "org.xmind.topicShape.underline"
: "org.xmind.topicShape.roundedRect";
copy("fo:color", leaf.color);
copy("fo:font-size", leaf["font-size"]);
copy("fo:font-family", leaf["font-family"]);
if (leaf.bold) properties["fo:font-weight"] = "bold";
if (leaf.italic) properties["fo:font-style"] = "italic";
if (leaf.underlined || leaf.underline)
properties["fo:text-decoration"] = "underline";
if (Object.keys(properties).length)
topic.style = { id: id(), properties };
if (details.notes)
topic.notes =
typeof metadata.notes === "object"
? metadata.notes
: { plain: { content: details.notes } };
if (details.labels.length) topic.labels = details.labels;
const markerSet = new Set<string>();
for (const emoji of array(data.emojis)) {
const marker = getXmindMarker(String(emoji.name))?.id;
if (marker) markerSet.add(marker);
else warnings.add("部分自定义图标尚不能写入 XMind 标记");
}
// Retain imported markers that this editor cannot render, but honor removal of supported ones.
for (const marker of array(metadata.markers))
if (
typeof marker.markerId === "string" &&
!getXmindMarker(marker.markerId)
)
markerSet.add(marker.markerId);
if (markerSet.size)
topic.markers = [...markerSet].map((markerId) => ({ markerId }));
if (details.topicLinkId && ids.has(details.topicLinkId))
topic.href = `xmind:#${ids.get(details.topicLinkId)}`;
else if (details.href) topic.href = details.href;
if (details.attachment) {
if (topic.href)
warnings.add("同时含附件和链接的主题优先导出附件,其他链接暂不导出");
topic.href = await embed(
details.attachment.url,
details.attachment.name
);
}
const picture = record(data.image);
if (typeof picture.url === "string") {
const mime =
picture.url.match(/^data:image\/(png|jpeg|gif|webp)/)?.[1] || "png";
topic.image = {
src: await embed(picture.url, `image.${mime}`),
width: picture.width,
height: picture.height,
};
}
const attached = (element.children || []).filter(
(child) => !summary(child)
);
const summaries = (element.children || []).filter(summary);
const groups: Data = {};
for (const child of attached) parents.set(child.id, element);
if (attached.length)
groups.attached = await Promise.all(attached.map(build));
if (summaries.length) {
groups.summary = await Promise.all(summaries.map(build));
topic.summaries = summaries.map((child) => ({
id: id(),
topicId: ids.get(child.id),
range: `(${child.start},${child.end})`,
}));
}
if (Object.keys(groups).length) topic.children = groups;
return topic;
};
const roots = elements.filter((element) =>
["mind", "mindmap"].includes(String(element.type))
);
if (!roots.length)
throw new Error(`“${source.title}”没有可导出的思维导图主题`);
const root = await build(roots[0]);
if (roots.length > 1) {
const detached = [];
const origin = Array.isArray(roots[0].points)
? (roots[0].points[0] as number[])
: [0, 0];
for (const element of roots.slice(1)) {
const topic = await build(element);
const point = Array.isArray(element.points)
? (element.points[0] as number[])
: [500, 0];
topic.position = { x: point[0] - origin[0], y: point[1] - origin[1] };
detached.push(topic);
}
root.children = { ...record(root.children), detached };
}
const relationships = [];
for (const element of elements) {
if (roots.includes(element)) continue;
if (!isMindAnnotation(element)) {
warnings.add("自由画布中的图形、文本和独立图片暂不导出到 XMind");
continue;
}
const annotation = element.mindAnnotation;
const style = {
id: id(),
properties: {
"line-color": String(element.strokeColor || "#64748b"),
"line-width": String(element.strokeWidth || 2),
},
};
if (annotation.kind === "relationship") {
if (!ids.has(annotation.sourceId) || !ids.has(annotation.targetId)) {
warnings.add("目标缺失的联系线未导出");
continue;
}
relationships.push({
id: id(),
end1Id: ids.get(annotation.sourceId),
end2Id: ids.get(annotation.targetId),
title: array(element.texts)
.map((item) => textOf(item.text))
.join("\n"),
style,
controlPoints: {
"0": { angle: 0, amount: 0.1 },
"1": { angle: 0, amount: 0.1 },
},
});
} else {
const parent = parents.get(annotation.topicIds[0]);
const siblings =
parent?.children?.filter((child) => !summary(child)) || [];
const indices = annotation.topicIds
.map((topicId) => siblings.findIndex((child) => child.id === topicId))
.sort((a, b) => a - b);
if (
!parent ||
!indices.length ||
indices[0] < 0 ||
indices.at(-1)! - indices[0] + 1 !== indices.length
) {
warnings.add("非连续同级主题的外框暂不导出");
continue;
}
const owner = converted.get(parent.id)!;
owner.boundaries = [
...array(owner.boundaries),
{
id: id(),
range: `(${indices[0]},${indices.at(-1)})`,
title: String(record(element.xmind).title || ""),
style,
},
];
}
}
sheets.push({
id: id(),
class: "sheet",
title: source.title,
rootTopic: root,
...record(record(topics.find(entry => !parents.has(entry.element.id))?.element.xmind).sheet),
relationships,
});
}
zip.file("content.json", JSON.stringify(sheets));
zip.file(
"metadata.json",
JSON.stringify({
dataStructureVersion: "2",
creator: { name: "SnapGen", version: "1.0" },
layoutEngineVersion: "4",
})
);
zip.file("manifest.json", JSON.stringify({ "file-entries": entries }));
return {
blob: await zip.generateAsync({
type: "blob",
compression: "DEFLATE",
mimeType: "application/vnd.xmind.workbook",
}),
warnings: [...warnings],
};
}