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.
361 lines
13 KiB
TypeScript
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],
|
|
};
|
|
}
|