|
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
type MindMapData = {
|
|
|
|
|
data: Record<string, unknown>;
|
|
|
|
|
children?: MindMapData[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readBlobAsDataUrl = (blob: Blob): Promise<string> =>
|
|
|
|
|
new Promise((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
reader.onload = () => resolve(String(reader.result || ""));
|
|
|
|
|
reader.onerror = (err) => reject(err);
|
|
|
|
|
reader.readAsDataURL(blob);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const getImageSizeFromBlob = async (blob: Blob): Promise<{ width: number; height: number } | null> => {
|
|
|
|
|
try {
|
|
|
|
|
if (typeof createImageBitmap === "function") {
|
|
|
|
|
const bmp = await createImageBitmap(blob);
|
|
|
|
|
const res = { width: bmp.width, height: bmp.height };
|
|
|
|
|
try {
|
|
|
|
|
bmp.close();
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
const img = new Image();
|
|
|
|
|
img.onload = () => {
|
|
|
|
|
const res = { width: img.naturalWidth, height: img.naturalHeight };
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
resolve(res);
|
|
|
|
|
};
|
|
|
|
|
img.onerror = () => {
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
resolve(null);
|
|
|
|
|
};
|
|
|
|
|
img.src = url;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const walkElements = (root: Element): Element[] => {
|
|
|
|
|
const stack: Element[] = [root];
|
|
|
|
|
const out: Element[] = [];
|
|
|
|
|
while (stack.length) {
|
|
|
|
|
const el = stack.pop()!;
|
|
|
|
|
out.push(el);
|
|
|
|
|
for (let i = el.children.length - 1; i >= 0; i--) {
|
|
|
|
|
stack.push(el.children[i] as Element);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const findFirstByLocalName = (root: Element, name: string): Element | null => {
|
|
|
|
|
for (const el of walkElements(root)) {
|
|
|
|
|
if (el.localName === name) return el;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const findFirstChildByLocalName = (root: Element, name: string): Element | null => {
|
|
|
|
|
for (const el of Array.from(root.children)) {
|
|
|
|
|
if ((el as Element).localName === name) return el as Element;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const findChildrenByLocalName = (root: Element, name: string): Element[] =>
|
|
|
|
|
Array.from(root.children).filter((el) => (el as Element).localName === name) as Element[];
|
|
|
|
|
|
|
|
|
|
const getTopicPlainText = (topicEl: Element): string => {
|
|
|
|
|
const textEl = findFirstChildByLocalName(topicEl, "Text");
|
|
|
|
|
const raw = textEl?.getAttribute("PlainText") ?? "";
|
|
|
|
|
return raw;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getTopicHyperlink = (topicEl: Element): { url: string; title: string } | null => {
|
|
|
|
|
const linkEl = findFirstChildByLocalName(topicEl, "Hyperlink");
|
|
|
|
|
if (!linkEl) return null;
|
|
|
|
|
const url = linkEl.getAttribute("Url") ?? "";
|
|
|
|
|
const title = linkEl.getAttribute("Title") ?? "";
|
|
|
|
|
if (!url) return null;
|
|
|
|
|
return { url, title };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const guessMimeFromImageType = (imageType: string): string => {
|
|
|
|
|
const t = (imageType || "").toLowerCase();
|
|
|
|
|
if (t.includes("png")) return "image/png";
|
|
|
|
|
if (t.includes("jpeg") || t.includes("jpg")) return "image/jpeg";
|
|
|
|
|
if (t.includes("gif")) return "image/gif";
|
|
|
|
|
if (t.includes("bmp")) return "image/bmp";
|
|
|
|
|
if (t.includes("svg")) return "image/svg+xml";
|
|
|
|
|
return "application/octet-stream";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const parseMmArchiveUriToZipPath = (uri: string): string | null => {
|
|
|
|
|
// 常见格式:mmarch://bin/<uuid>.bin
|
|
|
|
|
const raw = String(uri || "");
|
|
|
|
|
const lower = raw.toLowerCase();
|
|
|
|
|
const prefix = "mmarch://";
|
|
|
|
|
if (!lower.startsWith(prefix)) return null;
|
|
|
|
|
let rest = raw.slice(prefix.length);
|
|
|
|
|
while (rest.startsWith("/")) rest = rest.slice(1);
|
|
|
|
|
return rest || null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getTopicImageInfo = (topicEl: Element): { uri: string; mime: string } | null => {
|
|
|
|
|
const oneImage = findFirstChildByLocalName(topicEl, "OneImage");
|
|
|
|
|
if (!oneImage) return null;
|
|
|
|
|
const imageEl = findFirstByLocalName(oneImage, "Image");
|
|
|
|
|
if (!imageEl) return null;
|
|
|
|
|
const imageDataEl = findFirstByLocalName(imageEl, "ImageData");
|
|
|
|
|
const uriEl = imageEl ? findFirstByLocalName(imageEl, "Uri") : null;
|
|
|
|
|
const uri = (uriEl?.textContent ?? "").trim();
|
|
|
|
|
if (!uri) return null;
|
|
|
|
|
const mime = guessMimeFromImageType(imageDataEl?.getAttribute("ImageType") ?? "");
|
|
|
|
|
return { uri, mime };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const compactTree = (node: MindMapData, isRoot = false): MindMapData => {
|
|
|
|
|
const children = (node.children ?? []).map((c) => compactTree(c, false));
|
|
|
|
|
const data = node.data ?? {};
|
|
|
|
|
const hasContent =
|
|
|
|
|
Boolean(String(data.text ?? "").trim()) ||
|
|
|
|
|
Boolean(String((data as Record<string, unknown>).hyperlink ?? "").trim()) ||
|
|
|
|
|
Boolean(String((data as Record<string, unknown>).note ?? "").trim()) ||
|
|
|
|
|
Boolean(String((data as Record<string, unknown>).image ?? "").trim()) ||
|
|
|
|
|
Boolean(((data as Record<string, unknown>).tag as unknown[] | undefined)?.length) ||
|
|
|
|
|
Boolean(String((data as Record<string, unknown>).attachmentUrl ?? "").trim());
|
|
|
|
|
if (!isRoot && !hasContent && children.length === 1) {
|
|
|
|
|
return children[0];
|
|
|
|
|
}
|
|
|
|
|
return { ...node, children };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const parseMindManagerMmapFile = async (file: File): Promise<MindMapData> => {
|
|
|
|
|
const JSZip = (await import("jszip")).default;
|
|
|
|
|
const zip = await JSZip.loadAsync(file);
|
|
|
|
|
|
|
|
|
|
const xmlFile = zip.file("Document.xml");
|
|
|
|
|
if (!xmlFile) {
|
|
|
|
|
throw new Error("Document.xml 不存在");
|
|
|
|
|
}
|
|
|
|
|
const xmlText = await xmlFile.async("string");
|
|
|
|
|
const xmlDoc = new DOMParser().parseFromString(xmlText, "application/xml");
|
|
|
|
|
if (xmlDoc.getElementsByTagName("parsererror").length > 0) {
|
|
|
|
|
throw new Error("Document.xml 解析失败");
|
|
|
|
|
}
|
|
|
|
|
const docEl = xmlDoc.documentElement;
|
|
|
|
|
if (!docEl) throw new Error("Document.xml 内容为空");
|
|
|
|
|
|
|
|
|
|
const defaultsGroup = findFirstByLocalName(docEl, "RootTopicDefaultsGroup");
|
|
|
|
|
const defaultTextEl = defaultsGroup ? findFirstByLocalName(defaultsGroup, "DefaultText") : null;
|
|
|
|
|
const defaultRootText = defaultTextEl?.getAttribute("PlainText") ?? "中心主题";
|
|
|
|
|
|
|
|
|
|
const oneTopicEl = findFirstByLocalName(docEl, "OneTopic");
|
|
|
|
|
const rootTopicEl = oneTopicEl ? findFirstChildByLocalName(oneTopicEl, "Topic") : null;
|
|
|
|
|
if (!rootTopicEl) {
|
|
|
|
|
throw new Error("未找到根主题");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const binCache = new Map<string, { dataUrl: string; width: number; height: number }>();
|
|
|
|
|
|
|
|
|
|
const resolveBinToImageDataUrl = async (
|
|
|
|
|
uri: string,
|
|
|
|
|
mime: string,
|
|
|
|
|
): Promise<{ dataUrl: string; width: number; height: number } | null> => {
|
|
|
|
|
const zipPath = parseMmArchiveUriToZipPath(uri);
|
|
|
|
|
if (!zipPath) return null;
|
|
|
|
|
if (binCache.has(zipPath)) return binCache.get(zipPath)!;
|
|
|
|
|
|
|
|
|
|
const entry = zip.file(zipPath) ?? zip.file(`/${zipPath}`);
|
|
|
|
|
if (!entry) return null;
|
|
|
|
|
const bytes = await entry.async("uint8array");
|
|
|
|
|
const blob = new Blob([bytes], { type: mime });
|
|
|
|
|
const dataUrl = await readBlobAsDataUrl(blob);
|
|
|
|
|
const size = await getImageSizeFromBlob(blob);
|
|
|
|
|
const width = size?.width ?? 0;
|
|
|
|
|
const height = size?.height ?? 0;
|
|
|
|
|
const res = { dataUrl, width, height };
|
|
|
|
|
binCache.set(zipPath, res);
|
|
|
|
|
return res;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const walkTopic = async (topicEl: Element, isRoot: boolean): Promise<MindMapData> => {
|
|
|
|
|
const text = getTopicPlainText(topicEl);
|
|
|
|
|
const hyperlink = getTopicHyperlink(topicEl);
|
|
|
|
|
const imageInfo = getTopicImageInfo(topicEl);
|
|
|
|
|
const nodeData: Record<string, unknown> = {
|
|
|
|
|
text: text ?? "",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (hyperlink?.url) {
|
|
|
|
|
nodeData.hyperlink = hyperlink.url;
|
|
|
|
|
if (hyperlink.title) nodeData.hyperlinkTitle = hyperlink.title;
|
|
|
|
|
// MindManager 常见:文本为空但带链接标题,作为节点可见文本更符合导入预期
|
|
|
|
|
if (!String(nodeData.text || "").trim() && hyperlink.title) {
|
|
|
|
|
nodeData.text = hyperlink.title;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (imageInfo?.uri) {
|
|
|
|
|
const resolved = await resolveBinToImageDataUrl(imageInfo.uri, imageInfo.mime);
|
|
|
|
|
if (resolved?.dataUrl) {
|
|
|
|
|
nodeData.image = resolved.dataUrl;
|
|
|
|
|
nodeData.imageSize = {
|
|
|
|
|
width: resolved.width || 0,
|
|
|
|
|
height: resolved.height || 0,
|
|
|
|
|
custom: true,
|
|
|
|
|
};
|
|
|
|
|
nodeData.imgPlacement = "top";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRoot && !String(nodeData.text || "").trim()) {
|
|
|
|
|
nodeData.text = defaultRootText || "中心主题";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const subTopicsEl = findFirstChildByLocalName(topicEl, "SubTopics");
|
|
|
|
|
const childTopicEls = subTopicsEl ? findChildrenByLocalName(subTopicsEl, "Topic") : [];
|
|
|
|
|
const children = await Promise.all(childTopicEls.map((c) => walkTopic(c, false)));
|
|
|
|
|
return { data: nodeData, children };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const tree = await walkTopic(rootTopicEl, true);
|
|
|
|
|
return compactTree(tree, true);
|
|
|
|
|
};
|
|
|
|
|
|