ai-chatbot/lib/flow-board/xmind-export.ts
134 79253245f3 fix(flow-board): align Xmind editor UI and restore marquee feedback
Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support.

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 13:32:54 +08:00

357 lines
13 KiB
TypeScript

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 markerIds: Record<string, string> = {
"✅": "task-done",
"▶️": "task-start",
"🚩": "flag-red",
"⭐": "star-red",
...Object.fromEntries(
[1, 2, 3, 4, 5, 6, 7].map((n) => [`${n}️⃣`, `priority-${n}`])
),
};
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) },
],
},
];
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 = markerIds[String(emoji.name)];
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" &&
!Object.values(markerIds).includes(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,
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],
};
}