Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx
T
2026-01-02 07:25:50 +08:00

1226 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import "simple-mind-map/dist/simpleMindMap.esm.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import type { CustomBlockSchema } from "../schema";
import { MindmapToolbar } from "./MindmapToolbar";
import { MindmapSidebar } from "./MindmapSidebar";
import { MindmapSidebarTrigger } from "./MindmapSidebarTrigger";
import { MindmapNavigator } from "./MindmapNavigator";
import { MindmapMiniMap } from "./MindmapMiniMap";
import { MindmapCount } from "./MindmapCount";
import type { SidebarPanel } from "./mindmapSidebarConfig";
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 { emitAssetsChanged } from "@/lib/events";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
// @ts-expect-error 第三方库缺少类型定义
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> =>
new Promise((resolve) => {
if (!url) return resolve(null);
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve(null);
img.src = url;
});
type MindMapInstance = {
execCommand: (...args: unknown[]) => void;
destroy: () => void;
setData: (data: unknown) => void;
getData: (withConfig?: boolean) => unknown;
on?: (event: string, handler: (...args: unknown[]) => void) => void;
emit?: (event: string, ...args: unknown[]) => void;
off?: (event: string, handler: (...args: unknown[]) => void) => void;
setMode?: (mode: string) => void;
command: { clearHistory: () => void };
view: { fit: () => void; scale: number; enlarge: () => void; narrow: () => void; setScale: (scale: number, cx: number, cy: number) => void };
renderer: { activeNodeList: unknown[]; renderTree: { _node: unknown }; setRootNodeCenter: () => void };
painter?: { startPainter: () => void };
doExport?: { export: (type: string, isDownload?: boolean, name?: string) => Promise<unknown> };
width: number;
height: number;
miniMap?: unknown;
getThemeConfig: (key: string) => unknown;
setThemeConfig: (config: unknown) => void;
setTheme: (theme: string) => void;
setLayout: (layout: string) => void;
};
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: [],
};
const STORAGE_PREFIX = "wolai-mindmap-autosave-";
function downloadJson(data: unknown, name: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${name}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
const createSimplePrompt = (title: string, placeholder = "") => {
const value = window.prompt(title, placeholder);
if (!value || !value.trim()) return null;
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,
) => {
if (!mindmap) {
window.alert("思维导图尚未初始化");
return;
}
const list = mindmap.renderer?.activeNodeList;
if (!list || list.length === 0) {
// 尝试兜底选中根节点
const root = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node;
if (root) {
mindmap.execCommand?.("SET_NODE_ACTIVE", root, true);
} else {
window.alert("请选择至少一个节点再执行该操作");
return;
}
}
(mindmap.renderer?.activeNodeList ?? []).forEach(handler);
};
// 确保存在激活节点后再执行命令,若无则自动选中根节点
const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
const mm = mindmap ?? null;
if (!mm) {
window.alert("思维导图尚未初始化");
return null;
}
const list = mm.renderer?.activeNodeList ?? [];
if (!list || list.length === 0) {
const root = mm.renderer?.root ?? mm.renderer?.renderTree?._node;
if (root) {
mm.execCommand?.("SET_NODE_ACTIVE", root, true);
} else {
window.alert("请先选中一个节点");
return null;
}
}
return mm;
};
const MindmapBlockView = ({
block,
editor,
fullscreen = false,
}: {
block: Block<CustomBlockSchema, "mindmap">;
editor: BlockNoteEditor<CustomBlockSchema>;
fullscreen?: boolean;
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
const [painterMode, setPainterMode] = useState(false);
const [showMiniMap, setShowMiniMap] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
const [showNoteModal, setShowNoteModal] = useState(false);
const [noteContent, setNoteContent] = useState("");
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
const instance = mm ?? mindmap;
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]);
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;
if (cached) {
try {
initialDataRef.current = JSON.parse(cached);
} catch {
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 } });
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, 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;
const rootNode = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node;
if (rootNode) {
setActiveNodes([rootNode]);
mindmap.renderer.activeNodeList = [rootNode];
// @ts-expect-error 第三方库字段
mindmap.renderer.lastActiveNodeList = [rootNode];
mindmap.emit?.("node_active", rootNode, [rootNode]);
}
}, [mindmap, activeNodes.length]);
useEffect(() => {
let destroyed = false;
(async () => {
if (!containerRef.current) return;
const [
{ default: MindMap },
{ default: Painter },
{ default: AssociativeLine },
{ default: OuterFrame },
{ default: Exporter },
{ default: Formula },
{ default: RichText },
{ default: MiniMapPlugin },
{ default: Select },
{ 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"),
import("simple-mind-map/src/plugins/AssociativeLine.js"),
import("simple-mind-map/src/plugins/OuterFrame.js"),
import("simple-mind-map/src/plugins/Export.js"),
import("simple-mind-map/src/plugins/Formula.js"),
import("simple-mind-map/src/plugins/RichText.js"),
import("simple-mind-map/src/plugins/MiniMap.js"),
import("simple-mind-map/src/plugins/Select.js"),
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 = [
{ name: "Painter", plugin: Painter },
{ name: "AssociativeLine", plugin: AssociativeLine },
{ name: "OuterFrame", plugin: OuterFrame },
{ name: "Export", plugin: Exporter },
{ name: "Formula", plugin: Formula },
{ name: "RichText", plugin: RichText },
{ name: "MiniMap", plugin: MiniMapPlugin },
{ name: "Select", plugin: Select },
{ 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 }) => {
if (!plugin) {
console.warn(`思维导图插件加载失败:${name}`);
return;
}
// @ts-expect-error 第三方库缺少类型定义
if (MindMap.hasPlugin(plugin) === -1) {
console.log(`注册插件: ${name}`);
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: hostEl,
data: initialDataRef.current,
theme: "classic",
layout: "logicalStructure",
mousewheelAction: "zoom",
enableFreeDrag: true,
enableCtrlKeyNodeSelection: true,
fit: true,
useLeftKeySelectionRightKeyDrag: true,
// 传入扩展图标表,和官方一致
iconList: mergerIconList([
...nodeIconList,
...(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") {
// 便于开发阶段在控制台直接调试实例
// @ts-expect-error 调试用全局变量
window.__mindmapInstance = instance;
}
setMindmap(instance);
instance.on?.("back_forward", (index: number, len: number) => {
setCanBack(index > 0);
setCanForward(index < len - 1);
});
instance.on?.("node_active", (_node: unknown, list: unknown[]) => {
if (!list || list.length === 0) return;
setActiveNodes(list || []);
});
instance.on?.("node_click", (node: unknown) => {
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
// @ts-expect-error 第三方库节点对象
if (typeof node?.active === "function") {
// @ts-expect-error simple-mind-map 节点对象缺少类型
node.active();
} else {
// 兜底:手动维护激活列表
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.clearActiveNodeList?.();
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.addNodeToActiveList?.(node, true);
// @ts-expect-error simple-mind-map 渲染器缺少类型
instance.renderer?.emitNodeActiveEvent?.(node);
}
const list = instance.renderer?.activeNodeList ?? [];
setActiveNodes(list.length === 0 && node ? [node] : (list as unknown[]));
});
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
instance.on?.("data_change", (data: unknown) => {
debouncedPersist(data);
});
const rootNode = getRootNode(instance);
if (rootNode) {
setActiveNodes([rootNode]);
}
if (destroyed) {
instance.destroy();
}
})();
return () => {
destroyed = true;
setMindmap((prev) => {
prev?.destroy();
return null;
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [block.id]);
const handleUndo = () => mindmap?.execCommand("BACK");
const handleRedo = () => mindmap?.execCommand("FORWARD");
const handlePainter = () => {
if (!mindmap?.painter) {
window.alert("格式刷插件未就绪");
return;
}
mindmap.painter.startPainter();
};
const handleSibling = () => {
if (!mindmap) return;
window.setTimeout(() => mindmap.execCommand("INSERT_NODE"), 0);
};
const handleChild = () => {
if (!mindmap) return;
window.setTimeout(() => mindmap.execCommand("INSERT_CHILD_NODE"), 0);
};
const handleDelete = () => {
if (!mindmap) return;
window.setTimeout(() => mindmap.execCommand("REMOVE_NODE"), 0);
};
const handleSummary = () => {
const mm = ensureActiveBefore(mindmap);
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
}
};
const handleAssociativeLine = () => {
const mm = ensureActiveBefore(mindmap);
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
}
};
const handleOuterFrame = () => {
const mm = ensureActiveBefore(mindmap);
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
}
};
const [showImageModal, setShowImageModal] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const [imageWidth, setImageWidth] = useState<number | string>(260);
const [imageHeight, setImageHeight] = useState<number | string>(200);
const [imageTitle, setImageTitle] = useState("");
const [imagePosition, setImagePosition] = useState<string>("top");
const fileInputForImage = useRef<HTMLInputElement | null>(null);
// 图片预览(双击节点图片)
const [showImageViewer, setShowImageViewer] = useState(false);
const [viewerSrc, setViewerSrc] = useState("");
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
x: 0,
y: 0,
placement: "top" as "top" | "bottom" | "left" | "right",
});
const imgToolbarHover = useRef(false);
const handleImage = () => {
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
if (first?.getStyle) {
const placement = first.getStyle("imgPlacement", false) as string;
if (placement) setImagePosition(placement);
}
setShowImageModal(true);
};
// 预览:监听 mindmap 事件
useEffect(() => {
if (!mindmap) return;
const handler = (node: MindMapNode, e?: Event) => {
e?.stopPropagation?.();
e?.preventDefault?.();
const src =
node?.nodeData?.data?.image ||
node?.getData?.("image") ||
node?.data?.image;
if (src) {
setViewerSrc(src);
const size = node?.getData?.("imageSize") || {};
const title = node?.getData?.("imageTitle") || "";
setViewerMeta({
title: typeof title === "string" ? title : "",
width: Number(size?.width) || undefined,
height: Number(size?.height) || undefined,
});
setShowImageViewer(true);
}
};
const onActive = () => {
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: 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({
show: true,
x: bbox.left,
y: bbox.top,
placement,
});
};
const hideToolbar = () => {
if (!imgToolbarHover.current) {
setImgToolbarState((s) => ({ ...s, show: false }));
}
};
mindmap.on?.("node_img_dblclick", handler);
mindmap.on?.("node_active", onActive);
mindmap.on?.("node_img_click", showToolbarOnClick);
mindmap.on?.("draw_click", hideToolbar);
return () => {
mindmap.off?.("node_img_dblclick", handler);
mindmap.off?.("node_active", onActive);
mindmap.off?.("node_img_click", showToolbarOnClick);
mindmap.off?.("draw_click", hideToolbar);
};
}, [mindmap]);
// 悬浮图片位置工具条
const renderImgToolbar = () => {
if (!imgToolbarState.show) return null;
const { x, y, placement } = imgToolbarState;
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
setImagePosition(p);
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
);
setImgToolbarState((s) => ({ ...s, placement: p }));
};
const btn = (
p: typeof placement,
Icon: React.ComponentType<{ className?: string }>,
title: string,
) => (
<button
key={p}
type="button"
className={`flex h-8 w-8 items-center justify-center rounded border bg-white/90 text-gray-700 shadow ${
placement === p ? "border-blue-500 text-blue-600" : "border-gray-200"
}`}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
setPlacement(p);
}}
title={title}
>
<Icon className="h-4 w-4" />
</button>
);
return (
<div
ref={imgToolbarRef}
className="pointer-events-auto fixed z-[9999] flex gap-1 rounded-lg bg-black/50 p-2 backdrop-blur"
style={{ left: x, top: y - 42 }}
onMouseEnter={() => {
imgToolbarHover.current = true;
setImgToolbarState((s) => ({ ...s, show: true }));
}}
onMouseLeave={() => {
imgToolbarHover.current = false;
setImgToolbarState((s) => ({ ...s, show: false }));
}}
>
{btn("top", ArrowUp, "顶部")}
{btn("bottom", ArrowDown, "底部")}
{btn("left", ArrowLeft, "靠左")}
{btn("right", ArrowRight, "靠右")}
</div>
);
};
const handleIcon = () => {
setActiveSidebar("icons");
};
const handleLink = () => {
const href = createSimplePrompt("请输入超链接");
if (!href) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_HYPERLINK", node, href, href));
};
const handleNote = () => {
setShowNoteModal(true);
};
const handleTag = () => {
const tagsRaw = createSimplePrompt("请输入标签,使用逗号分隔", "重点,待验证");
if (!tagsRaw) return;
const tags = tagsRaw.split(",").map((item) => item.trim()).filter(Boolean);
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_TAG", node, tags));
};
const handleFormula = () => {
setActiveSidebar("formula");
};
const handleNoteConfirm = () => {
const note = noteContent.trim();
setShowNoteModal(false);
if (!note) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
};
const handleImageConfirm = () => {
const url = imageUrl.trim();
const width = Number(imageWidth) || 260;
const height = Number(imageHeight) || 200;
if (!url) {
window.alert("请输入图片链接");
return;
}
setShowImageModal(false);
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand("SET_NODE_IMAGE", node, {
url,
width,
height,
title: imageTitle || "",
position: imagePosition || "top",
}),
);
applyToActiveNodes(mindmap, (node) =>
mindmap?.execCommand?.("SET_NODE_STYLES", node, {
imgPlacement: imagePosition || "top",
}),
);
};
const handleAttachment = () => {
const url = createSimplePrompt("附件链接(http/https");
if (!url) return;
const name = createSimplePrompt("附件名称(可选)", "附件");
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_ATTACHMENT", node, url, name ?? ""));
};
const handleAiPlaceholder = () => {
window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。");
};
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
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);
return;
}
// 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 = () => {
if (!window.confirm("确定要新建空白导图吗?当前未保存的修改将被覆盖。")) return;
mindmap?.setData(defaultMindmapData);
mindmap?.command.clearHistory();
debouncedPersist(defaultMindmapData);
};
const handleOpenDirectory = () => {
const saved = window.localStorage.getItem(autosaveKey);
if (!saved) {
window.alert("暂无本地目录记录,可使用“导入”载入文件。");
return;
}
try {
const data = JSON.parse(saved);
mindmap?.setData(data);
window.alert("已从本地目录恢复最新自动保存版本。");
} catch {
window.alert("本地目录数据损坏,建议重新导入。");
}
};
const handleExportJson = () => {
const data = mindmap?.getData?.(true) ?? block.props.data ?? defaultMindmapData;
downloadJson(data, "mindmap");
};
const handleExport = async (type: string, name = "mindmap") => {
try {
await mindmap?.doExport?.export(type, true, name);
} catch (error) {
console.error(error);
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,
painterMode,
onUndo: handleUndo,
onRedo: handleRedo,
onPainter: handlePainter,
onSibling: handleSibling,
onChild: handleChild,
onDelete: handleDelete,
onImage: handleImage,
onIcon: handleIcon,
onLink: handleLink,
onNote: handleNote,
onTag: handleTag,
onSummary: handleSummary,
onAssociativeLine: handleAssociativeLine,
onFormula: handleFormula,
onAttachment: handleAttachment,
onOuterFrame: handleOuterFrame,
onAi: handleAiPlaceholder,
onImport: handleImport,
onNew: handleNew,
onOpenDirectory: handleOpenDirectory,
onSaveAs: handleSaveAs,
onDeleteMindmap: handleDeleteMindmap,
onExportJson: handleExportJson,
onExportPng: handleExportPng,
onExportSvg: handleExportSvg,
onExportPdf: handleExportPdf,
onExportMd: handleExportMd,
onExportTxt: handleExportTxt,
onExportXmind: handleExportXmind,
fileInputRef,
};
const noteModal = showNoteModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-lg font-semibold text-gray-800">备注</span>
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowNoteModal(false)}>
</button>
</div>
<div className="px-4 py-3">
<textarea
className="h-64 w-full resize-none rounded-md border border-gray-200 p-3 text-sm focus:border-blue-400 focus:outline-none"
placeholder="支持富文本/Markdown,内容将写入节点备注"
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
/>
</div>
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
<button
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setShowNoteModal(false)}
>
取消
</button>
<button
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
onClick={handleNoteConfirm}
>
确定
</button>
</div>
</div>
</div>
) : null;
const imageViewer = showImageViewer ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
<div className="relative max-h-full max-w-5xl text-white">
<button
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
onClick={(e) => {
e.stopPropagation();
setShowImageViewer(false);
}}
>
关闭
</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="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 || "预览"}
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
) : null;
const imgToolbar = renderImgToolbar();
const imageModal = showImageModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-3xl rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-lg font-semibold text-gray-800">插入图片</span>
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowImageModal(false)}>
</button>
</div>
<div className="space-y-4 px-4 py-3">
<div className="space-y-2">
<Label className="text-xs font-semibold text-gray-600">方式一:上传图片</Label>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => fileInputForImage.current?.click()}
>
选择文件
</button>
<span className="text-xs text-gray-400 truncate">
{imageUrl.startsWith("data:") ? "已选择本地图片" : "未选择文件"}
</span>
<input
ref={fileInputForImage}
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
const dataUrl = String(reader.result);
setImageUrl(dataUrl);
const size = await getImageSizeSafe(dataUrl);
if (size) {
setImageWidth(size.width);
setImageHeight(size.height);
}
};
reader.readAsDataURL(file);
}}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold text-gray-600">方式二:图片地址</Label>
<Input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.png"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold text-gray-600">快捷位置</Label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "top" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("top")}
title="顶部"
>
<ArrowUp className="mx-auto h-4 w-4" />
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "bottom" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("bottom")}
title="底部"
>
<ArrowDown className="mx-auto h-4 w-4" />
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "left" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("left")}
title="靠左"
>
<ArrowLeft className="mx-auto h-4 w-4" />
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "right" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("right")}
title="靠右"
>
<ArrowRight className="mx-auto h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-500">宽度(px</Label>
<Input
type="number"
value={imageWidth}
onChange={(e) => setImageWidth(e.target.value)}
min={10}
placeholder="260"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">高度(px</Label>
<Input
type="number"
value={imageHeight}
onChange={(e) => setImageHeight(e.target.value)}
min={10}
placeholder="200"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">标题(可选)</Label>
<Input
value={imageTitle}
onChange={(e) => setImageTitle(e.target.value)}
placeholder="图片标题"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">位置</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 px-3 text-sm"
value={imagePosition}
onChange={(e) => setImagePosition(e.target.value)}
>
<option value="center">居中</option>
<option value="top">顶部</option>
<option value="bottom">底部</option>
<option value="left">靠左</option>
<option value="right">靠右</option>
</select>
</div>
<p className="text-xs text-gray-400">可选择本地图片或粘贴 URL,默认尺寸 260×200</p>
</div>
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
<button
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setShowImageModal(false)}
>
取消
</button>
<button
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
onClick={handleImageConfirm}
>
插入
</button>
</div>
</div>
</div>
) : null;
if (fullscreen) {
return (
<div className="relative h-screen w-screen overflow-hidden bg-white">
<div className="fixed left-1/2 top-4 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full">
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
onSelect={setActiveSidebar}
/>
{/* @ts-expect-error mindmap type */}
<MindmapSidebar
mindmap={mindmap}
activeNodes={activeNodes} // @ts-expect-error type
activeTab={activeSidebar}
onClose={() => setActiveSidebar(null)}
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={fullscreen}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
<MindmapCount mindmap={mindmap} />
{/* @ts-expect-error mindmap type */}
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
{imgToolbar}
{noteModal}
{imageModal}
{imageViewer}
</div>
</div>
);
}
return (
<div className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm">
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
<div className="w-full">
<MindmapToolbar {...toolbarProps} />
</div>
</div>
<div className="relative h-[520px] w-full overflow-hidden bg-white">
<div
ref={containerRef}
className="h-full w-full"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
onSelect={setActiveSidebar}
/>
{/* @ts-expect-error mindmap type */}
<MindmapSidebar
mindmap={mindmap}
activeNodes={activeNodes} // @ts-expect-error type
activeTab={activeSidebar}
onClose={() => setActiveSidebar(null)}
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={fullscreen}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
<MindmapCount mindmap={mindmap} />
{/* @ts-expect-error mindmap type */}
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
{imgToolbar}
{noteModal}
{imageModal}
{imageViewer}
</div>
</div>
);
};
export { MindmapBlockView };
export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
docId: { default: "" },
data: { default: defaultMindmapData },
},
content: "none",
},
{
render: (props) => <MindmapBlockView {...props} />,
},
);