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.
665 lines
24 KiB
TypeScript
665 lines
24 KiB
TypeScript
import type JSZip from "jszip";
|
||
import { getTopicEntries } from "./topic-details";
|
||
import {
|
||
SPECIAL_MIND_LAYOUTS,
|
||
specialLayoutForStructure,
|
||
} from "./mind-special-layout";
|
||
import type { PlaitElement } from "@plait/core";
|
||
import {
|
||
ArrowLineMarkerType,
|
||
ArrowLineShape,
|
||
BasicShapes,
|
||
createArrowLineElement,
|
||
createGeometryElementWithoutText,
|
||
} from "@plait/draw";
|
||
import { MindLayoutType } from "@plait/layouts";
|
||
import {
|
||
createMindElement,
|
||
BranchShape,
|
||
MindElementShape,
|
||
type MindElement,
|
||
type PlaitMind,
|
||
} from "@plait/mind";
|
||
|
||
export class MindMapImportError extends Error {}
|
||
export type ImportedMindMap = {
|
||
sheets: { id: string; title: string; children: PlaitElement[] }[];
|
||
warnings: string[];
|
||
};
|
||
type Data = Record<string, unknown>;
|
||
const record = (value: unknown): Data =>
|
||
value && typeof value === "object" && !Array.isArray(value)
|
||
? (value as Data)
|
||
: {};
|
||
const list = (value: unknown): Data[] =>
|
||
Array.isArray(value)
|
||
? value.filter(
|
||
(item) => item && typeof item === "object" && !Array.isArray(item)
|
||
)
|
||
: [];
|
||
const text = (value: unknown, fallback = "") =>
|
||
typeof value === "string" ? value.trim() || fallback : fallback;
|
||
const children = (element: Element, name: string) =>
|
||
Array.from(element.children).filter((child) => child.localName === name);
|
||
const first = (element: Element, name: string) => children(element, name)[0];
|
||
const color = (value: unknown) =>
|
||
typeof value === "string" && /^#[\da-f]{3,8}$/i.test(value)
|
||
? value
|
||
: undefined;
|
||
|
||
function spreadsheetColumns(topic: Data) {
|
||
const extension = list(topic.extensions).find(
|
||
(item) => item.provider === "org.xmind.ui.spreadsheet"
|
||
);
|
||
const result: string[] = [];
|
||
const visit = (value: unknown) => {
|
||
if (Array.isArray(value)) {
|
||
value.forEach(visit);
|
||
return;
|
||
}
|
||
const item = record(value);
|
||
if (item.name === "column") {
|
||
const value = text(item.content);
|
||
if (value) result.push(value);
|
||
return;
|
||
}
|
||
Object.values(item).forEach(visit);
|
||
};
|
||
visit(extension?.content);
|
||
return [...new Set(result)];
|
||
}
|
||
|
||
// XMind's public model: https://github.com/xmindltd/xmind-model/tree/master/schemas
|
||
const layouts: Record<string, MindLayoutType> = {
|
||
"org.xmind.ui.map.unbalanced": MindLayoutType.standard,
|
||
"org.xmind.ui.map.clockwise": MindLayoutType.standard,
|
||
"org.xmind.ui.logic.right": MindLayoutType.right,
|
||
"org.xmind.ui.logic.left": MindLayoutType.left,
|
||
"org.xmind.ui.org-chart.down": MindLayoutType.downward,
|
||
"org.xmind.ui.org-chart.up": MindLayoutType.upward,
|
||
"org.xmind.ui.tree.right": MindLayoutType.rightBottomIndented,
|
||
"org.xmind.ui.tree.left": MindLayoutType.leftBottomIndented,
|
||
};
|
||
|
||
function parseXml(source: string) {
|
||
const doc = new DOMParser().parseFromString(source, "application/xml");
|
||
if (doc.getElementsByTagName("parsererror").length)
|
||
throw new MindMapImportError("XMind 文件中的 XML 内容已损坏");
|
||
return doc;
|
||
}
|
||
|
||
function xmlSheets(source: string, stylesSource?: string): Data[] {
|
||
const styles = new Map<string, Data>();
|
||
if (stylesSource) {
|
||
for (const element of Array.from(
|
||
parseXml(stylesSource).getElementsByTagNameNS("*", "style")
|
||
)) {
|
||
const properties: Data = {};
|
||
for (const property of Array.from(element.children)) {
|
||
for (const attribute of Array.from(property.attributes))
|
||
properties[attribute.name] = attribute.value;
|
||
}
|
||
styles.set(element.getAttribute("id") || "", properties);
|
||
}
|
||
}
|
||
const style = (element: Element) => ({
|
||
properties: styles.get(element.getAttribute("style-id") || "") || {},
|
||
});
|
||
const convert = (element: Element, depth = 0): Data => {
|
||
if (depth > 128)
|
||
throw new MindMapImportError("主题层级超过 128 层,请拆分后导入");
|
||
const childGroups: Data = {};
|
||
const container = first(element, "children");
|
||
if (container)
|
||
for (const group of children(container, "topics")) {
|
||
const kind = group.getAttribute("type") || "attached";
|
||
childGroups[kind] = children(group, "topic").map((child) =>
|
||
convert(child, depth + 1)
|
||
);
|
||
}
|
||
const collection = (containerName: string, itemName: string) => {
|
||
const parent = first(element, containerName);
|
||
return parent
|
||
? children(parent, itemName).map((item) => ({
|
||
...Object.fromEntries(
|
||
Array.from(item.attributes).map((attr) => [attr.name, attr.value])
|
||
),
|
||
title: first(item, "title")?.textContent || "",
|
||
style: style(item),
|
||
topicId: item.getAttribute("topic-id"),
|
||
}))
|
||
: [];
|
||
};
|
||
const notes = first(element, "notes");
|
||
const plainNote = notes && first(notes, "plain");
|
||
const labels = first(element, "labels");
|
||
const image =
|
||
first(element, "image") ||
|
||
Array.from(element.children).find((child) => child.localName === "img");
|
||
const position = first(element, "position");
|
||
return {
|
||
id: element.getAttribute("id"),
|
||
title: first(element, "title")?.textContent,
|
||
structureClass: element.getAttribute("structure-class"),
|
||
branch: element.getAttribute("branch"),
|
||
children: childGroups,
|
||
style: style(element),
|
||
notes: (plainNote || notes)?.textContent?.trim() || undefined,
|
||
href:
|
||
element.getAttributeNS("http://www.w3.org/1999/xlink", "href") ||
|
||
undefined,
|
||
labels: labels
|
||
? children(labels, "label")
|
||
.map((label) => label.textContent || "")
|
||
.filter(Boolean)
|
||
: undefined,
|
||
image: image
|
||
? {
|
||
src:
|
||
image.getAttribute("src") ||
|
||
image.getAttributeNS("http://www.w3.org/1999/xlink", "href"),
|
||
width: image.getAttribute("width"),
|
||
height: image.getAttribute("height"),
|
||
}
|
||
: undefined,
|
||
position: position
|
||
? {
|
||
x: Number(position.getAttribute("x")),
|
||
y: Number(position.getAttribute("y")),
|
||
}
|
||
: undefined,
|
||
summaries: collection("summaries", "summary"),
|
||
boundaries: collection("boundaries", "boundary"),
|
||
markers: collection("marker-refs", "marker-ref").map((marker) => ({
|
||
markerId: record(marker)["marker-id"],
|
||
})),
|
||
extensions: first(element, "extensions")
|
||
? Array.from(first(element, "extensions")!.children).map(
|
||
(extension) => {
|
||
const extensionItem = (item: Element): Data => ({
|
||
name: item.localName,
|
||
content: item.children.length
|
||
? Array.from(item.children).map(extensionItem)
|
||
: item.textContent,
|
||
});
|
||
return {
|
||
provider: extension.getAttribute("provider"),
|
||
content: Array.from(extension.children).map(extensionItem),
|
||
};
|
||
}
|
||
)
|
||
: [],
|
||
};
|
||
};
|
||
const doc = parseXml(source);
|
||
return Array.from(doc.getElementsByTagNameNS("*", "sheet")).map((sheet) => {
|
||
const root = first(sheet, "topic");
|
||
const relationships = first(sheet, "relationships");
|
||
return {
|
||
title: first(sheet, "title")?.textContent,
|
||
rootTopic: root ? convert(root) : undefined,
|
||
relationships: relationships
|
||
? children(relationships, "relationship").map((item) => ({
|
||
end1Id: item.getAttribute("end1"),
|
||
end2Id: item.getAttribute("end2"),
|
||
title: first(item, "title")?.textContent,
|
||
style: style(item),
|
||
}))
|
||
: [],
|
||
};
|
||
});
|
||
}
|
||
|
||
function parseRange(value: unknown, count: number) {
|
||
const match = text(value).match(/^\((\d+)\s*,\s*(\d+)\)$/);
|
||
if (!match) return null;
|
||
const start = Number(match[1]);
|
||
const end = Number(match[2]);
|
||
return start <= end && end < count ? { start, end } : null;
|
||
}
|
||
|
||
function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||
const warnings = new Set<string>();
|
||
let nodeCount = 0;
|
||
if (
|
||
!sheets.length ||
|
||
sheets.some((sheet) => !Object.keys(record(sheet.rootTopic)).length)
|
||
) {
|
||
throw new MindMapImportError("XMind 文件没有有效的工作表或中心主题");
|
||
}
|
||
return {
|
||
sheets: sheets.map((sheet, index) => {
|
||
const topicIds = new Map<string, string>();
|
||
const floating: PlaitElement[] = [];
|
||
const annotations: PlaitElement[] = [];
|
||
const theme = record(sheet.theme);
|
||
const palette = text(record(record(theme.map).properties)["multi-line-colors"] || record(record(theme.map).properties)["color-list"]).split(/\s+/).map(color).filter((value): value is string => Boolean(value));
|
||
const build = (
|
||
topic: Data,
|
||
depth: number,
|
||
root = false,
|
||
floatingRoot = false,
|
||
inheritedBranchColor = ""
|
||
): MindElement => {
|
||
if (++nodeCount > 10_000 || depth > 128)
|
||
throw new MindMapImportError(
|
||
"文件超过 10000 个主题或 128 层,请拆分后导入"
|
||
);
|
||
const element = createMindElement(text(topic.title, "未命名主题"), {});
|
||
if (typeof topic.id === "string") topicIds.set(topic.id, element.id);
|
||
const groups = Array.isArray(topic.children)
|
||
? { attached: topic.children }
|
||
: record(topic.children);
|
||
const structure = text(topic.structureClass);
|
||
const specialLayout = specialLayoutForStructure(structure);
|
||
if (specialLayout) element.specialLayout = specialLayout;
|
||
if (root && specialLayout === "spreadsheet") {
|
||
const columns = spreadsheetColumns(topic);
|
||
if (columns.length) element.spreadsheetColumns = columns;
|
||
}
|
||
if (specialLayout && !root) warnings.add("子树的特殊结构暂未单独排布");
|
||
if (structure && !layouts[structure] && !specialLayout)
|
||
warnings.add("部分特殊结构已转换为逻辑图");
|
||
if (structure || root)
|
||
element.layout = specialLayout
|
||
? (SPECIAL_MIND_LAYOUTS[specialLayout].base as MindLayoutType)
|
||
: layouts[structure] || MindLayoutType.right;
|
||
element.isCollapsed = topic.branch === "folded";
|
||
const properties = {
|
||
...record(
|
||
record(
|
||
theme[
|
||
floatingRoot
|
||
? "floatingTopic"
|
||
: root
|
||
? "centralTopic"
|
||
: depth === 1
|
||
? "mainTopic"
|
||
: "subTopic"
|
||
]
|
||
).properties
|
||
),
|
||
...record(record(topic.style).properties),
|
||
};
|
||
const fill = color(properties["svg:fill"]);
|
||
if (properties["fill-pattern"] === "none") element.fill = "transparent";
|
||
else if (fill) element.fill = fill;
|
||
else if (properties["svg:fill"] === "inherited" && inheritedBranchColor) element.fill = inheritedBranchColor;
|
||
const stroke = color(properties["border-line-color"]);
|
||
if (stroke) element.strokeColor = stroke;
|
||
const branch = color(properties["line-color"]);
|
||
if (branch) element.branchColor = branch;
|
||
else if (inheritedBranchColor) element.branchColor = inheritedBranchColor;
|
||
const borderWidth = Number.parseFloat(
|
||
String(properties["border-line-width"] || "")
|
||
);
|
||
if (Number.isFinite(borderWidth))
|
||
element.strokeWidth = Math.max(0, Math.min(16, borderWidth));
|
||
if (borderWidth === 0 || properties["border-line-pattern"] === "none") element.strokeColor = "transparent";
|
||
const branchWidth = Number.parseFloat(
|
||
String(properties["line-width"] || "")
|
||
);
|
||
if (Number.isFinite(branchWidth))
|
||
element.branchWidth = Math.max(0, Math.min(16, branchWidth));
|
||
if (properties["line-class"] === "org.xmind.branchConnection.elbow")
|
||
element.branchShape = BranchShape.polyline;
|
||
else if (
|
||
properties["line-class"] === "org.xmind.branchConnection.curve"
|
||
)
|
||
element.branchShape = BranchShape.bight;
|
||
const shape = text(properties["shape-class"]);
|
||
if (shape.endsWith(".underline"))
|
||
element.shape = MindElementShape.underline;
|
||
else if (shape.endsWith(".roundedRect"))
|
||
element.shape = MindElementShape.roundRectangle;
|
||
const fontSize = Number.parseFloat(
|
||
String(properties["fo:font-size"] || "")
|
||
);
|
||
const leaf = {
|
||
text: text(topic.title, "未命名主题"),
|
||
...(properties["fo:color"] === "inherited" ? { color: "#000000" } : {}),
|
||
...(color(properties["fo:color"])
|
||
? { color: color(properties["fo:color"]) }
|
||
: {}),
|
||
...(Number.isFinite(fontSize)
|
||
? { "font-size": Math.max(8, Math.min(96, fontSize)) }
|
||
: {}),
|
||
...(properties["fo:font-weight"] === "bold" ||
|
||
Number(properties["fo:font-weight"]) >= 600
|
||
? { bold: true }
|
||
: {}),
|
||
...(properties["fo:font-style"] === "italic" ? { italic: true } : {}),
|
||
...(properties["fo:text-decoration"] === "underline"
|
||
? { underlined: true }
|
||
: {}),
|
||
...(typeof properties["fo:font-family"] === "string"
|
||
? { "font-family": properties["fo:font-family"] }
|
||
: {}),
|
||
};
|
||
element.data.topic.children = [leaf];
|
||
const metadata: Data = {};
|
||
for (const key of ["notes", "href", "labels", "markers", "image"])
|
||
if (topic[key] !== undefined) metadata[key] = topic[key];
|
||
if (Object.keys(metadata).length) element.xmind = metadata;
|
||
|
||
const emojis: string[] = [];
|
||
for (const marker of list(topic.markers)) {
|
||
const id = text(marker.markerId);
|
||
const priority = id.match(/^priority-([1-7])$/);
|
||
const mapped = priority
|
||
? `${priority[1]}️⃣`
|
||
: (
|
||
{
|
||
"task-done": "✅",
|
||
"task-start": "▶️",
|
||
"flag-red": "🚩",
|
||
"star-red": "⭐",
|
||
} as Record<string, string>
|
||
)[id];
|
||
if (mapped) emojis.push(mapped);
|
||
else warnings.add("部分图标已保留为数据,暂不显示");
|
||
}
|
||
if (emojis.length)
|
||
element.data.emojis = emojis.map((name) => ({ name }));
|
||
element.children = list(groups.attached).map((child,index) =>
|
||
build(child, depth + 1, false, false, depth === 0 && palette.length ? palette[index % palette.length] : inheritedBranchColor)
|
||
);
|
||
const attachedCount = element.children.length;
|
||
if (element.layout === MindLayoutType.standard) {
|
||
const extension = list(topic.extensions).find(
|
||
(item) => item.provider === structure
|
||
);
|
||
const right = list(extension?.content).find(
|
||
(item) => item.name === "right-number"
|
||
);
|
||
const n = Number(right?.content ?? Math.ceil(attachedCount / 2));
|
||
element.rightNodeCount = Number.isInteger(n)
|
||
? Math.max(0, Math.min(attachedCount, n))
|
||
: Math.ceil(attachedCount / 2);
|
||
}
|
||
const usedSummaries = new Set<string>();
|
||
for (const summary of list(topic.summaries)) {
|
||
const range = parseRange(summary.range, attachedCount);
|
||
const summaryTopic = list(groups.summary).find(
|
||
(item) => item.id === summary.topicId
|
||
);
|
||
if (range && summaryTopic) {
|
||
const child = build(summaryTopic, depth + 1);
|
||
child.start = range.start;
|
||
child.end = range.end;
|
||
element.children.push(child);
|
||
usedSummaries.add(text(summaryTopic.id));
|
||
} else warnings.add("部分概要范围无法还原");
|
||
}
|
||
for (const boundary of list(topic.boundaries)) {
|
||
const range = parseRange(boundary.range, attachedCount);
|
||
if (!range) {
|
||
warnings.add("部分外框范围无法还原");
|
||
continue;
|
||
}
|
||
const props = record(record(boundary.style).properties);
|
||
const shape = createGeometryElementWithoutText(
|
||
BasicShapes.roundRectangle,
|
||
[
|
||
[0, 0],
|
||
[1, 1],
|
||
],
|
||
{
|
||
fill: "transparent",
|
||
strokeColor: color(props["line-color"]) || "#8b9bb8",
|
||
strokeWidth: 2,
|
||
}
|
||
);
|
||
annotations.push({
|
||
...shape,
|
||
mindAnnotation: {
|
||
kind: "boundary",
|
||
topicIds: element.children
|
||
.slice(range.start, range.end + 1)
|
||
.map((child) => child.id),
|
||
padding: 18,
|
||
},
|
||
xmind: { title: boundary.title },
|
||
});
|
||
if (boundary.title) warnings.add("外框标题已保留为数据,暂不显示");
|
||
}
|
||
const extraTopics = [
|
||
...list(groups.detached),
|
||
...list(groups.callout),
|
||
...list(groups.summary).filter(
|
||
(item) => !usedSummaries.has(text(item.id))
|
||
),
|
||
];
|
||
if (list(groups.callout).length)
|
||
warnings.add("标注主题已转换为自由主题");
|
||
for (const extra of extraTopics) {
|
||
const detached = build(extra, depth + 1, true, true) as PlaitMind;
|
||
const pos = record(extra.position);
|
||
detached.points = [
|
||
[
|
||
typeof pos.x === "number" && Number.isFinite(pos.x) ? pos.x : 500,
|
||
typeof pos.y === "number" && Number.isFinite(pos.y)
|
||
? pos.y
|
||
: floating.length * 180,
|
||
],
|
||
];
|
||
floating.push(detached);
|
||
}
|
||
if (root) {
|
||
element.type = "mindmap";
|
||
element.isRoot = true;
|
||
element.points = [[0, 0]];
|
||
}
|
||
return element;
|
||
};
|
||
const root = build(record(sheet.rootTopic), 0, true);
|
||
for (const { element } of getTopicEntries([root, ...floating])) {
|
||
const metadata = record(element.xmind);
|
||
if (
|
||
typeof metadata.href === "string" &&
|
||
metadata.href.startsWith("xmind:#")
|
||
) {
|
||
const targetId = topicIds.get(metadata.href.slice(7));
|
||
if (targetId)
|
||
element.xmind = { ...metadata, href: "", topicLinkId: targetId };
|
||
else warnings.add("部分主题链接的目标不在当前画布,暂未还原");
|
||
}
|
||
}
|
||
for (const relationship of list(sheet.relationships)) {
|
||
const sourceId = topicIds.get(text(relationship.end1Id));
|
||
const targetId = topicIds.get(text(relationship.end2Id));
|
||
if (!sourceId || !targetId) {
|
||
warnings.add("部分联系线的目标主题缺失,未能还原");
|
||
continue;
|
||
}
|
||
const label = {
|
||
type: "paragraph",
|
||
children: [{ text: text(relationship.title) }],
|
||
};
|
||
const props = record(record(relationship.style).properties);
|
||
const shape = createArrowLineElement(
|
||
ArrowLineShape.curve,
|
||
[
|
||
[0, 0],
|
||
[1, 1],
|
||
],
|
||
{ marker: ArrowLineMarkerType.none },
|
||
{ marker: ArrowLineMarkerType.arrow },
|
||
relationship.title ? [{ position: 0.5, text: label }] : [],
|
||
{
|
||
strokeColor: color(props["line-color"]) || "#64748b",
|
||
strokeWidth: 2,
|
||
}
|
||
);
|
||
annotations.push({
|
||
...shape,
|
||
mindAnnotation: { kind: "relationship", sourceId, targetId },
|
||
});
|
||
}
|
||
return {
|
||
id: `xmind-${crypto.randomUUID()}`,
|
||
title: text(
|
||
sheet.title,
|
||
sheets.length === 1 ? fallbackTitle : `画布 ${index + 1}`
|
||
),
|
||
children: [
|
||
...annotations.filter((item) => item.type === "geometry"),
|
||
root,
|
||
...floating,
|
||
...annotations.filter((item) => item.type !== "geometry"),
|
||
],
|
||
};
|
||
}),
|
||
warnings: [...warnings],
|
||
};
|
||
}
|
||
|
||
async function restoreResources(
|
||
result: ImportedMindMap,
|
||
zip: JSZip
|
||
): Promise<ImportedMindMap> {
|
||
const warnings = new Set(result.warnings);
|
||
const cache = new Map<string, { url: string; bytes: Uint8Array }>();
|
||
let totalBytes = 0;
|
||
const readResource = async (source: string) => {
|
||
const path = decodeURIComponent(
|
||
source.replace(/^xap:/, "").replace(/^\//, "")
|
||
);
|
||
if (/^[a-z]+:/i.test(path) || path.split("/").includes("..")) return null;
|
||
const file =
|
||
zip.file(path) ||
|
||
Object.values(zip.files).find(
|
||
(entry) => !entry.dir && entry.name.endsWith(`/${path}`)
|
||
);
|
||
if (!file) return null;
|
||
if (cache.has(file.name)) return cache.get(file.name)!;
|
||
const bytes = await file.async("uint8array");
|
||
totalBytes += bytes.length;
|
||
if (bytes.length > 8 * 1024 * 1024 || totalBytes > 24 * 1024 * 1024) {
|
||
warnings.add("部分内嵌资源超过大小限制,暂未导入");
|
||
return null;
|
||
}
|
||
const value = { bytes, url: await file.async("base64") };
|
||
cache.set(file.name, value);
|
||
return value;
|
||
};
|
||
for (const sheet of result.sheets)
|
||
for (const { element } of getTopicEntries(sheet.children)) {
|
||
const metadata = record(element.xmind);
|
||
const image = record(metadata.image);
|
||
if (typeof image.src === "string") {
|
||
try {
|
||
const resource = await readResource(image.src);
|
||
if (!resource) throw new Error("Missing image");
|
||
const b = resource.bytes;
|
||
const mime =
|
||
b[0] === 137 && b[1] === 80
|
||
? "image/png"
|
||
: b[0] === 255 && b[1] === 216
|
||
? "image/jpeg"
|
||
: String.fromCharCode(...b.slice(0, 3)) === "GIF"
|
||
? "image/gif"
|
||
: String.fromCharCode(...b.slice(0, 4)) === "RIFF" &&
|
||
String.fromCharCode(...b.slice(8, 12)) === "WEBP"
|
||
? "image/webp"
|
||
: null;
|
||
if (!mime) throw new Error("Unsupported image");
|
||
const bitmap = await createImageBitmap(
|
||
new Blob([new Uint8Array(b)], { type: mime })
|
||
);
|
||
const requested = Number(image.width);
|
||
const width = Math.min(
|
||
500,
|
||
requested > 0 ? requested : Math.min(320, bitmap.width)
|
||
);
|
||
const height = (width * bitmap.height) / bitmap.width;
|
||
bitmap.close();
|
||
element.data = {
|
||
...record(element.data),
|
||
image: {
|
||
url: `data:${mime};base64,${resource.url}`,
|
||
width,
|
||
height,
|
||
},
|
||
};
|
||
} catch {
|
||
warnings.add("部分图片缺失、损坏或格式暂不支持");
|
||
}
|
||
}
|
||
if (
|
||
typeof metadata.href === "string" &&
|
||
metadata.href.startsWith("xap:")
|
||
) {
|
||
try {
|
||
const resource = await readResource(metadata.href);
|
||
if (!resource) throw new Error("Missing attachment");
|
||
const name = decodeURIComponent(
|
||
metadata.href.split("/").pop() || "附件"
|
||
);
|
||
element.xmind = {
|
||
...metadata,
|
||
href: "",
|
||
attachment: {
|
||
name,
|
||
mimeType: "application/octet-stream",
|
||
url: `data:application/octet-stream;base64,${resource.url}`,
|
||
},
|
||
};
|
||
} catch {
|
||
warnings.add("部分附件缺失或超过大小限制,暂未导入");
|
||
}
|
||
}
|
||
}
|
||
return { ...result, warnings: [...warnings] };
|
||
}
|
||
|
||
export async function importXmind(
|
||
file: File,
|
||
fallbackTitle: string
|
||
): Promise<ImportedMindMap> {
|
||
if (file.size > 50 * 1024 * 1024)
|
||
throw new MindMapImportError("XMind 文件超过 50 MB,请拆分后导入");
|
||
const { default: JSZip } = await import("jszip");
|
||
let zip;
|
||
try {
|
||
zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||
} catch {
|
||
throw new MindMapImportError("无法打开 XMind 文件,请确认文件完整且未加密");
|
||
}
|
||
const entry = (name: string) =>
|
||
zip.file(name) ||
|
||
Object.values(zip.files).find(
|
||
(item) => !item.dir && item.name.toLowerCase().endsWith(`/${name}`)
|
||
);
|
||
const json = entry("content.json");
|
||
const xml = entry("content.xml");
|
||
try {
|
||
if (json) {
|
||
const parsed: unknown = JSON.parse(await json.async("string"));
|
||
const sheets = Array.isArray(parsed)
|
||
? list(parsed)
|
||
: list(record(parsed).sheets);
|
||
return await restoreResources(convertSheets(sheets, fallbackTitle), zip);
|
||
}
|
||
if (xml)
|
||
return await restoreResources(
|
||
convertSheets(
|
||
xmlSheets(
|
||
await xml.async("string"),
|
||
await entry("styles.xml")?.async("string")
|
||
),
|
||
fallbackTitle
|
||
),
|
||
zip
|
||
);
|
||
} catch (error) {
|
||
if (error instanceof MindMapImportError) throw error;
|
||
throw new MindMapImportError(
|
||
"XMind 内容解析失败,请在 XMind 中重新保存后再导入"
|
||
);
|
||
}
|
||
throw new MindMapImportError(
|
||
"文件中未找到 XMind 内容,请选择有效的 .xmind 文件"
|
||
);
|
||
}
|