双向删除同步

This commit is contained in:
liaibo
2026-01-02 07:25:50 +08:00
parent 1db64c1c55
commit b1288487af
48 changed files with 2406 additions and 8841 deletions
@@ -17,9 +17,15 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
import iconConfig from "./mindmapIconConfig";
import { nodeIconList } from "simple-mind-map/src/svg/icons.js";
import { emitAssetsChanged } from "@/lib/events";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
// @ts-expect-error 第三方库缺少类型定义
import { mergerIconList } from "simple-mind-map/src/utils/index.js";
const loadIconModules = async () => {
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
const { mergerIconList } = await import("simple-mind-map/src/utils/index.js");
return { nodeIconList, mergerIconList };
};
// 安全获取图片尺寸
const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> =>
@@ -54,7 +60,19 @@ type MindMapInstance = {
setLayout: (layout: string) => void;
};
const defaultMindmapData = {
type MindMapNode = {
getStyle?: (key: string, inherit?: boolean) => unknown;
getData?: (key: string) => unknown;
nodeData?: { data?: Record<string, unknown> };
data?: Record<string, unknown>;
};
type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
export const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
@@ -77,6 +95,39 @@ const createSimplePrompt = (title: string, placeholder = "") => {
return value.trim();
};
// 修补 svg.js rbox 在节点未挂载时抛出的异常
const patchSvgRbox = async () => {
// 仅在浏览器环境生效
if (typeof window === "undefined") return;
const svgModule = await import("@svgdotjs/svg.js");
const candidates = [(svgModule as any).Element, (svgModule as any).G];
candidates.forEach((Ctor) => {
if (!Ctor?.prototype) return;
if (Ctor.prototype.__wolaiRboxPatched) return;
const original = Ctor.prototype.rbox;
if (typeof original !== "function") return;
// @ts-expect-error 动态扩展第三方原型
Ctor.prototype.rbox = function patchedRbox(ref?: unknown) {
try {
return original.call(this, ref);
} catch (error) {
console.warn("rbox 失败,返回空边界框以避免崩溃", error);
return {
x: 0,
y: 0,
width: 0,
height: 0,
x2: 0,
y2: 0,
cx: 0,
cy: 0,
};
}
};
Ctor.prototype.__wolaiRboxPatched = true;
});
};
const applyToActiveNodes = (
mindmap: MindMapInstance | null,
handler: (node: unknown) => void,
@@ -134,7 +185,6 @@ const MindmapBlockView = ({
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
const activeCount = activeNodes.length;
const [painterMode, setPainterMode] = useState(false);
const [showMiniMap, setShowMiniMap] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
@@ -146,7 +196,19 @@ const MindmapBlockView = ({
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]);
const autosaveKey = useMemo(() => `${STORAGE_PREFIX}${block.id}`, [block.id]);
const docId = useMemo(
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const autosaveKey = useMemo(
() => `${STORAGE_PREFIX}${docId || block.id}`,
[block.id, docId],
);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -154,26 +216,83 @@ const MindmapBlockView = ({
try {
initialDataRef.current = JSON.parse(cached);
} catch {
initialDataRef.current = block.props.data ?? defaultMindmapData;
initialDataRef.current = block.props.data ?? defaultMindmapData;
}
} else {
initialDataRef.current = block.props.data ?? defaultMindmapData;
}
}
// 优先加载本地文件,其次 Supabase(通过后端 API
useEffect(() => {
let cancelled = false;
if (!docId) return;
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}`);
if (!resp.ok) return;
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
initialDataRef.current = data;
if (mindmap) {
mindmap.setData(data);
mindmap.command.clearHistory();
}
} catch (error) {
console.warn("加载本地/远端思维导图失败", error);
}
})();
return () => {
cancelled = true;
};
}, [docId, mindmap]);
const persistData = useCallback(
(data: unknown) => {
if (!editor) return;
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
editor.updateBlock(block, { props: { ...block.props, data } });
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
editor.updateBlock(block, { props: { ...block.props, data } });
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
.then((resp) => {
if (resp.ok) {
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
}
})
.catch((err) => console.warn("思维导图同步失败", err));
}
},
[autosaveKey, block, editor],
[autosaveKey, block, docId, editor],
);
const debouncedPersist = useDebouncedCallback((data: unknown) => {
persistData(data);
}, 500);
const initialSyncDone = useRef(false);
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData;
void fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
.then((resp) => {
if (resp.ok) {
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
}
})
.catch((err) => console.warn("初次创建思维导图文件失败", err));
}, [docId, mindmap, initialDataRef]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
useEffect(() => {
if (!mindmap || activeNodes.length > 0) return;
@@ -204,6 +323,16 @@ const MindmapBlockView = ({
{ default: Drag },
{ default: KeyboardNavigation },
{ default: NodeImgAdjust },
{ default: Scrollbar },
{ default: RainbowLines },
{ default: Watermark },
{ default: TouchEvent },
{ default: Cooperate },
{ default: Demonstrate },
{ default: MindMapLayoutPro },
{ default: NodeBase64ImageStorage },
{ default: ExportPDF },
{ default: ExportXMind },
] = await Promise.all([
import("simple-mind-map"),
import("simple-mind-map/src/plugins/Painter.js"),
@@ -217,6 +346,16 @@ const MindmapBlockView = ({
import("simple-mind-map/src/plugins/Drag.js"),
import("simple-mind-map/src/plugins/KeyboardNavigation.js"),
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
import("simple-mind-map/src/plugins/Scrollbar.js"),
import("simple-mind-map/src/plugins/RainbowLines.js"),
import("simple-mind-map/src/plugins/Watermark.js"),
import("simple-mind-map/src/plugins/TouchEvent.js"),
import("simple-mind-map/src/plugins/Cooperate.js"),
import("simple-mind-map/src/plugins/Demonstrate.js"),
import("simple-mind-map/src/plugins/MindMapLayoutPro.js"),
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
import("simple-mind-map/src/plugins/ExportPDF.js"),
import("simple-mind-map/src/plugins/ExportXMind.js"),
]);
const plugins = [
@@ -231,6 +370,16 @@ const MindmapBlockView = ({
{ name: "Drag", plugin: Drag },
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
{ name: "Scrollbar", plugin: Scrollbar },
{ name: "RainbowLines", plugin: RainbowLines },
{ name: "Watermark", plugin: Watermark },
{ name: "TouchEvent", plugin: TouchEvent },
{ name: "Cooperate", plugin: Cooperate },
{ name: "Demonstrate", plugin: Demonstrate },
{ name: "MindMapLayoutPro", plugin: MindMapLayoutPro },
{ name: "NodeBase64ImageStorage", plugin: NodeBase64ImageStorage },
{ name: "ExportPDF", plugin: ExportPDF },
{ name: "ExportXMind", plugin: ExportXMind },
];
plugins.forEach(({ name, plugin }) => {
@@ -241,13 +390,23 @@ const MindmapBlockView = ({
// @ts-expect-error 第三方库缺少类型定义
if (MindMap.hasPlugin(plugin) === -1) {
console.log(`注册插件: ${name}`);
// @ts-expect-error 第三方库缺少类型定义
MindMap.usePlugin(plugin);
const registerPlugin =
// @ts-expect-error simple-mind-map 插件注册无类型声明
(MindMap as { usePlugin?: (p: unknown) => void }).usePlugin;
if (registerPlugin) {
registerPlugin(plugin);
}
}
});
const { nodeIconList, mergerIconList } = await loadIconModules();
await patchSvgRbox();
const hostEl = containerRef.current ?? document.body;
const instance = new MindMap({
el: containerRef.current,
el: hostEl,
data: initialDataRef.current,
theme: "classic",
layout: "logicalStructure",
@@ -262,6 +421,17 @@ const MindmapBlockView = ({
...(iconConfig as unknown[]),
]),
}) as MindMapInstance;
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
if (!(instance as any).commonCaches) {
(instance as any).commonCaches = {};
}
if (!(instance as any).commonCaches.measureRichtextNodeTextSizeEl) {
const measureDiv = document.createElement("div");
measureDiv.style.position = "fixed";
measureDiv.style.left = "-999999px";
((instance as any).commonCaches).measureRichtextNodeTextSizeEl = measureDiv;
(hostEl ?? document.body).appendChild(measureDiv);
}
instance.setMode?.("edit");
if (typeof window !== "undefined") {
@@ -284,15 +454,15 @@ const MindmapBlockView = ({
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
// @ts-expect-error 第三方库节点对象
if (typeof node?.active === "function") {
// @ts-expect-error 第三方库节点对象
// @ts-expect-error simple-mind-map 节点对象缺少类型
node.active();
} else {
// 兜底:手动维护激活列表
// @ts-expect-error 第三方库缺少类型定义
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.clearActiveNodeList?.();
// @ts-expect-error
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.addNodeToActiveList?.(node, true);
// @ts-expect-error
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.emitNodeActiveEvent?.(node);
}
const list = instance.renderer?.activeNodeList ?? [];
@@ -374,9 +544,7 @@ const MindmapBlockView = ({
// 图片预览(双击节点图片)
const [showImageViewer, setShowImageViewer] = useState(false);
const [viewerSrc, setViewerSrc] = useState("");
const [viewerNode, setViewerNode] = useState<any>(null);
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
const [hasActiveImg, setHasActiveImg] = useState(false);
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
@@ -387,9 +555,9 @@ const MindmapBlockView = ({
const imgToolbarHover = useRef(false);
const handleImage = () => {
const first = mindmap?.renderer?.activeNodeList?.[0] as any;
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
if (first?.getStyle) {
const placement = first.getStyle("imgPlacement", false) as string;
const placement = first.getStyle("imgPlacement", false) as string;
if (placement) setImagePosition(placement);
}
setShowImageModal(true);
@@ -398,7 +566,7 @@ const MindmapBlockView = ({
// 预览:监听 mindmap 事件
useEffect(() => {
if (!mindmap) return;
const handler = (node: any, e: any) => {
const handler = (node: MindMapNode, e?: Event) => {
e?.stopPropagation?.();
e?.preventDefault?.();
const src =
@@ -407,7 +575,6 @@ const MindmapBlockView = ({
node?.data?.image;
if (src) {
setViewerSrc(src);
setViewerNode(node);
const size = node?.getData?.("imageSize") || {};
const title = node?.getData?.("imageTitle") || "";
setViewerMeta({
@@ -419,14 +586,14 @@ const MindmapBlockView = ({
}
};
const onActive = () => {
const list = mindmap.renderer?.activeNodeList || [];
const has = list.some((n: any) => !!n?.getData?.("image"));
setHasActiveImg(has);
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
const has = list.some((n) => !!n?.getData?.("image"));
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
};
const showToolbarOnClick = (node: any, svgImg: any, evt: any) => {
const bbox = evt?.target?.getBoundingClientRect?.();
const placement = (node?.getStyle?.("imgPlacement") || "top") as any;
const showToolbarOnClick = (node: MindMapNode, _svgImg: unknown, evt: Event | undefined) => {
const target = evt?.target as Element | undefined;
const bbox = target?.getBoundingClientRect?.();
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
if (!bbox) return;
setImagePosition(placement);
setImgToolbarState({
@@ -460,11 +627,15 @@ const MindmapBlockView = ({
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
setImagePosition(p);
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
);
setImgToolbarState((s) => ({ ...s, placement: p }));
};
const btn = (p: typeof placement, Icon: any, title: string) => (
const btn = (
p: typeof placement,
Icon: React.ComponentType<{ className?: string }>,
title: string,
) => (
<button
key={p}
type="button"
@@ -571,24 +742,64 @@ const MindmapBlockView = ({
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
};
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const data = JSON.parse(String(reader.result));
const reset = () => {
event.target.value = "";
};
const ext = (file.name.split(".").pop() || "").toLowerCase();
try {
// JSON / smm
if (ext === "json" || ext === "smm") {
const text = await file.text();
const data = JSON.parse(text);
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
} catch (error) {
console.error(error);
window.alert("导入失败:文件格式错误");
} finally {
event.target.value = "";
return;
}
};
reader.readAsText(file, "utf-8");
// XMind
if (ext === "xmind") {
const xmindParser = await import("simple-mind-map/src/parse/xmind.js");
const blob = new Blob([await file.arrayBuffer()]);
const data = await xmindParser.default.parseXmindFile(blob, (content: unknown[]) => {
const list = Array.isArray(content) ? content : [];
if (list.length > 1) {
window.alert("检测到 XMind 多画布,自动导入第一个画布。");
}
return list.length > 0 ? list[0] : content;
});
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
// Markdown
if (ext === "md" || ext === "markdown") {
const { transformMarkdownTo } = await import(
"simple-mind-map/src/parse/markdownTo.js"
);
const text = await file.text();
const data = transformMarkdownTo(text) as MindMapData;
if (!data.data) {
data.data = { text: file.name.replace(/\.(md|markdown)$/i, "") || "中心主题" };
}
mindmap?.setData(data);
mindmap?.command.clearHistory();
debouncedPersist(data);
return;
}
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .md");
} catch (error) {
console.error(error);
window.alert("导入失败:文件格式或内容错误");
} finally {
reset();
}
};
const handleNew = () => {
@@ -618,21 +829,49 @@ const MindmapBlockView = ({
downloadJson(data, "mindmap");
};
const handleExportPng = async () => {
const handleExport = async (type: string, name = "mindmap") => {
try {
await mindmap?.doExport?.export("png", true, "mindmap");
await mindmap?.doExport?.export(type, true, name);
} catch (error) {
console.error(error);
window.alert("导出 PNG 失败,请稍后再试");
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
}
};
const handleExportPng = () => handleExport("png");
const handleExportSvg = () => handleExport("svg");
const handleExportPdf = () => handleExport("pdf");
const handleExportMd = () => handleExport("md");
const handleExportTxt = () => handleExport("txt");
const handleExportXmind = () => handleExport("xmind");
const handleSaveAs = () => handleExportJson();
const handleDeleteMindmap = useCallback(async () => {
if (!docId) {
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
if (!confirmed) return;
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
if (!resp.ok) {
const payload = await resp.json().catch(() => ({}));
window.alert(payload?.error ?? "删除思维导图失败");
return;
}
try {
window.localStorage.removeItem(autosaveKey);
} catch {
// ignore
}
emitAssetsChanged(docId);
editor.removeBlocks([block.id]);
}, [autosaveKey, block.id, docId, editor]);
const toolbarProps = {
canBack,
canForward,
activeCount,
painterMode,
onUndo: handleUndo,
onRedo: handleRedo,
@@ -655,8 +894,14 @@ const MindmapBlockView = ({
onNew: handleNew,
onOpenDirectory: handleOpenDirectory,
onSaveAs: handleSaveAs,
onDeleteMindmap: handleDeleteMindmap,
onExportJson: handleExportJson,
onExportPng: handleExportPng,
onExportSvg: handleExportSvg,
onExportPdf: handleExportPdf,
onExportMd: handleExportMd,
onExportTxt: handleExportTxt,
onExportXmind: handleExportXmind,
fileInputRef,
};
@@ -708,9 +953,10 @@ const MindmapBlockView = ({
</button>
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={viewerSrc}
alt={viewerMeta.title || "预览"}
@@ -884,7 +1130,11 @@ const MindmapBlockView = ({
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -923,7 +1173,11 @@ const MindmapBlockView = ({
</div>
</div>
<div className="relative h-[520px] w-full overflow-hidden bg-white">
<div ref={containerRef} className="h-full w-full" />
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -960,6 +1214,7 @@ export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
docId: { default: "" },
data: { default: defaultMindmapData },
},
content: "none",