Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx
T

1566 lines
58 KiB
TypeScript
Raw Normal View History

"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";
2025-12-31 19:26:38 +08:00
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
2025-12-30 18:39:38 +08:00
import iconConfig from "./mindmapIconConfig";
2026-01-02 07:25:50 +08:00
import { emitAssetsChanged } from "@/lib/events";
2026-01-05 06:59:54 +08:00
import { useEditorBridgeStore } from "@/store/editor-bridge";
2026-01-02 07:25:50 +08:00
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
2025-12-30 18:39:38 +08:00
// @ts-expect-error 第三方库缺少类型定义
2026-01-02 07:25:50 +08:00
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 };
};
2025-12-31 19:26:38 +08:00
// 安全获取图片尺寸
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;
};
2026-01-02 07:25:50 +08:00
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();
};
2026-01-02 07:25:50 +08:00
// 修补 svg.js rbox 在节点未挂载时抛出的异常
const patchSvgRbox = async () => {
// 仅在浏览器环境生效
if (typeof window === "undefined") return;
const svgModule = await import("@svgdotjs/svg.js");
2026-01-05 06:59:54 +08:00
const candidates = [(svgModule as any).Element, (svgModule as any).G];
let warned = false;
2026-01-02 07:25:50 +08:00
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) {
2026-01-05 06:59:54 +08:00
// 退化到 DOM 的 getBoundingClientRect 计算,避免初次渲染时出现 (0,0)
const el =
// svg.js Element 实例通常持有 node 属性
(this as any)?.node ?? (this as any)?.el ?? null;
const rect =
el && typeof el.getBoundingClientRect === "function"
? el.getBoundingClientRect()
: null;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 0;
const defaultWidth = Math.max(120, Math.floor(viewportWidth * 0.2));
const defaultHeight = Math.max(60, Math.floor(viewportHeight * 0.1));
const fallback = rect && rect.width && rect.height
? {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
x2: rect.x + rect.width,
y2: rect.y + rect.height,
cx: rect.x + rect.width / 2,
cy: rect.y + rect.height / 2,
}
: {
x: Math.max(0, (viewportWidth - defaultWidth) / 2),
y: Math.max(0, (viewportHeight - defaultHeight) / 2),
width: defaultWidth,
height: defaultHeight,
x2: Math.max(0, (viewportWidth + defaultWidth) / 2),
y2: Math.max(0, (viewportHeight + defaultHeight) / 2),
cx: Math.max(0, viewportWidth / 2),
cy: Math.max(0, viewportHeight / 2),
};
if (!warned) {
console.warn("rbox 失败,使用 DOM 边界框降级避免崩溃", error, fallback);
warned = true;
}
return fallback;
2026-01-02 07:25:50 +08:00
}
};
Ctor.prototype.__wolaiRboxPatched = true;
});
};
const applyToActiveNodes = (
mindmap: MindMapInstance | null,
handler: (node: unknown) => void,
) => {
if (!mindmap) {
window.alert("思维导图尚未初始化");
return;
}
2026-01-05 06:59:54 +08:00
const renderer = mindmap.renderer;
const list = renderer?.activeNodeList;
if (!list || list.length === 0) {
2026-01-05 06:59:54 +08:00
// 优先尝试 lastActiveNodeList(部分操作会短暂清空 activeNodeList
const lastActive = (renderer as any)?.lastActiveNodeList?.[0];
const root = renderer?.root ?? renderer?.renderTree?._node;
const fallback = lastActive ?? root;
if (fallback) {
renderer?.addNodeToActiveList?.(fallback);
mindmap.execCommand?.("SET_NODE_ACTIVE", fallback, true);
} else {
window.alert("请选择至少一个节点再执行该操作");
return;
}
}
2026-01-05 06:59:54 +08:00
(renderer?.activeNodeList ?? []).forEach(handler);
};
// 确保存在激活节点后再执行命令,若无则自动选中根节点
const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
const mm = mindmap ?? null;
if (!mm) {
window.alert("思维导图尚未初始化");
return null;
}
2026-01-05 06:59:54 +08:00
const renderer = mm.renderer;
const list = renderer?.activeNodeList ?? [];
if (!list || list.length === 0) {
2026-01-05 06:59:54 +08:00
const lastActive = (renderer as any)?.lastActiveNodeList?.[0];
const root = renderer?.root ?? renderer?.renderTree?._node;
const fallback = lastActive ?? root;
if (fallback) {
// 主动维护激活列表,避免首次插入子节点时因无激活节点导致插入位置错误
// 先确保根节点居中,避免后续插入节点跑到 (0,0)
renderer?.setRootNodeCenter && renderer.setRootNodeCenter();
mm.view?.fit?.();
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(fallback, true);
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(fallback);
mm.execCommand?.("SET_NODE_ACTIVE", fallback, true);
} else {
window.alert("请先选中一个节点");
return null;
}
}
return mm;
};
const MindmapBlockView = ({
block,
editor,
fullscreen = false,
}: {
block: Block<CustomBlockSchema, "mindmap">;
editor: BlockNoteEditor<CustomBlockSchema>;
fullscreen?: boolean;
}) => {
2026-01-05 06:59:54 +08:00
const currentDocumentId = useEditorBridgeStore(
(state) => state.currentDocumentId,
);
const containerRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
2026-01-05 06:59:54 +08:00
const mindmapReadyRef = useRef(false);
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);
2025-12-30 18:39:38 +08:00
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]);
2026-01-02 07:25:50 +08:00
const docId = useMemo(
() =>
block.props.docId ||
2026-01-05 06:59:54 +08:00
currentDocumentId ||
2026-01-02 07:25:50 +08:00
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
2026-01-05 06:59:54 +08:00
[block.props.docId, currentDocumentId],
2026-01-02 07:25:50 +08:00
);
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 {
2026-01-02 07:25:50 +08:00
initialDataRef.current = block.props.data ?? defaultMindmapData;
}
} else {
initialDataRef.current = block.props.data ?? defaultMindmapData;
}
}
2026-01-02 07:25:50 +08:00
// 优先加载本地文件,其次 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;
2026-01-02 07:25:50 +08:00
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) {
2026-01-05 06:59:54 +08:00
emitAssetsChanged(docId, {
id: `mindmap-${docId}`,
document_id: docId,
asset_type: "mindmap",
file_name: "mindmap.json",
file_url: `/documents/${docId}`,
});
2026-01-02 07:25:50 +08:00
}
})
.catch((err) => console.warn("思维导图同步失败", err));
}
},
2026-01-02 07:25:50 +08:00
[autosaveKey, block, docId, editor],
);
const debouncedPersist = useDebouncedCallback((data: unknown) => {
persistData(data);
}, 500);
2026-01-02 07:25:50 +08:00
const initialSyncDone = useRef(false);
useEffect(() => {
if (!docId || !mindmap || initialSyncDone.current) return;
initialSyncDone.current = true;
const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData;
2026-01-05 06:59:54 +08:00
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
});
if (!resp.ok) {
console.warn(
"初次创建思维导图文件失败",
resp.status,
await resp.text().catch(() => ""),
);
2026-01-02 07:25:50 +08:00
}
2026-01-05 06:59:54 +08:00
} catch (err) {
console.warn("初次创建思维导图文件失败", err);
} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
emitAssetsChanged(docId, {
id: `mindmap-${docId}`,
document_id: docId,
asset_type: "mindmap",
file_name: "mindmap.json",
file_url: `/documents/${docId}`,
});
}
})();
2026-01-02 07:25:50 +08:00
}, [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]);
2026-01-05 06:59:54 +08:00
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap.json
useEffect(() => {
if (!docId || !mindmap) return;
emitAssetsChanged(docId, {
id: `mindmap-${docId}`,
document_id: docId,
asset_type: "mindmap",
file_name: "mindmap.json",
file_url: `/documents/${docId}`,
});
}, [docId, mindmap]);
useEffect(() => {
let destroyed = false;
2026-01-05 06:59:54 +08:00
let createdInstance: MindMapInstance | null = null;
(async () => {
if (!containerRef.current) return;
2026-01-05 06:59:54 +08:00
const hostContainer = containerRef.current;
const [
{ default: MindMap },
{ default: Painter },
{ default: AssociativeLine },
{ default: OuterFrame },
{ default: Exporter },
{ default: Formula },
{ default: RichText },
{ default: MiniMapPlugin },
{ default: Select },
{ default: Drag },
{ default: KeyboardNavigation },
2025-12-31 19:26:38 +08:00
{ default: NodeImgAdjust },
2026-01-02 07:25:50 +08:00
{ default: Scrollbar },
{ default: RainbowLines },
{ default: Watermark },
{ default: TouchEvent },
{ default: Cooperate },
{ default: Demonstrate },
{ default: MindMapLayoutPro },
{ default: NodeBase64ImageStorage },
{ default: ExportPDF },
{ default: ExportXMind },
2026-01-05 06:59:54 +08:00
MindMapNodeModule,
] = 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"),
2025-12-31 19:26:38 +08:00
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
2026-01-02 07:25:50 +08:00
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"),
2026-01-05 06:59:54 +08:00
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
2026-01-02 07:25:50 +08:00
import("simple-mind-map/src/plugins/ExportPDF.js"),
import("simple-mind-map/src/plugins/ExportXMind.js"),
2026-01-05 06:59:54 +08:00
import("simple-mind-map/src/core/render/node/MindMapNode.js"),
]);
2026-01-05 06:59:54 +08:00
// React StrictMode(开发环境)会触发 effect 的“挂载-卸载-再挂载”流程:
// 若异步加载完成时已经卸载,不应再创建实例,否则可能留下残余 DOM,导致出现多个 root。
if (destroyed) return;
// 兜底修补 simple-mind-map 在创建富文本节点时 host/缓存未就绪导致的空指针
const MindMapNodeCtor = (MindMapNodeModule as any)?.default;
if (
MindMapNodeCtor &&
!MindMapNodeCtor.prototype.__wolaiRichtextPatched
) {
const originalCreate = MindMapNodeCtor.prototype.createRichTextNode;
MindMapNodeCtor.prototype.__wolaiRichtextPatched = true;
// @ts-expect-error 第三方库原型扩展
MindMapNodeCtor.prototype.createRichTextNode = function patched(...args: unknown[]) {
// host 兜底:优先使用实例容器,否则退回 body
const host: HTMLElement =
(this?.mindMap?.el as HTMLElement | null) ?? document.body;
if (!this.mindMap) {
this.mindMap = { el: host, commonCaches: {} } as any;
}
if (!this.mindMap.el || typeof (this.mindMap.el as any).appendChild !== "function") {
this.mindMap.el = host;
}
if (!this.mindMap.commonCaches) {
this.mindMap.commonCaches = {};
}
if (!this.mindMap.commonCaches.measureRichtextNodeTextSizeEl) {
const measureDiv = document.createElement("div");
measureDiv.style.position = "fixed";
measureDiv.style.left = "-999999px";
(this.mindMap.el ?? document.body).appendChild(measureDiv);
this.mindMap.commonCaches.measureRichtextNodeTextSizeEl = measureDiv;
}
if (typeof originalCreate === "function") {
return originalCreate.apply(this, args);
}
return null;
};
}
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 },
2025-12-31 19:26:38 +08:00
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
2026-01-02 07:25:50 +08:00
{ 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}`);
2026-01-02 07:25:50 +08:00
const registerPlugin =
// @ts-expect-error simple-mind-map 插件注册无类型声明
(MindMap as { usePlugin?: (p: unknown) => void }).usePlugin;
if (registerPlugin) {
registerPlugin(plugin);
}
}
});
2026-01-02 07:25:50 +08:00
const { nodeIconList, mergerIconList } = await loadIconModules();
await patchSvgRbox();
2026-01-05 06:59:54 +08:00
if (destroyed) return;
const hostEl = hostContainer;
// 防止同一容器残留旧实例的 svg/domStrictMode 或异常 destroy 场景)
try {
hostEl.replaceChildren();
} catch {
hostEl.innerHTML = "";
}
2026-01-02 07:25:50 +08:00
const instance = new MindMap({
2026-01-02 07:25:50 +08:00
el: hostEl,
data: initialDataRef.current,
theme: "classic",
layout: "logicalStructure",
mousewheelAction: "zoom",
enableFreeDrag: true,
enableCtrlKeyNodeSelection: true,
fit: true,
useLeftKeySelectionRightKeyDrag: true,
2026-01-05 06:59:54 +08:00
// 新建节点默认激活,编辑由我们手动触发,避免初次插入时定位到 (0,0)
createNewNodeBehavior: "activeOnly",
2025-12-30 18:39:38 +08:00
// 传入扩展图标表,和官方一致
iconList: mergerIconList([
...nodeIconList,
...(iconConfig as unknown[]),
]),
}) as MindMapInstance;
2026-01-05 06:59:54 +08:00
createdInstance = instance;
// 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用
//render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。
mindmapReadyRef.current = true;
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
2026-01-02 07:25:50 +08:00
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");
2026-01-05 06:59:54 +08:00
const centerAndFit = (retry = 0) => {
try {
const hostRect = hostEl.getBoundingClientRect?.();
if (!hostRect || hostRect.width < 10 || hostRect.height < 10) {
if (retry < 5) {
window.setTimeout(() => centerAndFit(retry + 1), 80);
}
return;
}
// 只要容器尺寸已可用,就允许工具栏命令执行;render_end 可能早于监听注册触发
//(尤其在首次构造时同步渲染),因此不要完全依赖 render_end 来标记就绪。
mindmapReadyRef.current = true;
const renderer = instance.renderer;
const rootNode =
// @ts-expect-error 第三方库字段
renderer?.root ??
// @ts-expect-error 第三方库字段
renderer?.renderTree?._node;
if (rootNode && renderer?.setRootNodeCenter) {
renderer.setRootNodeCenter();
}
instance.view?.fit?.();
} catch (error) {
if (retry < 3) {
window.setTimeout(() => centerAndFit(retry + 1), 50);
} else {
console.warn("思维导图初始居中失败,已跳过", error);
}
}
};
window.requestAnimationFrame(() => centerAndFit());
window.setTimeout(() => centerAndFit(), 80);
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);
});
2026-01-05 06:59:54 +08:00
// 渲染完成后再二次居中,并确保根节点被标记为激活
const handleRenderEnd = () => {
const renderer = instance.renderer;
const root = getRootNode(instance);
if (root) {
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true);
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
}
mindmapReadyRef.current = true;
console.log("[mindmap] render_end activeLen", renderer?.activeNodeList?.length ?? 0);
centerAndFit();
};
// 注意:部分版本的 simple-mind-map 中 `once` 回调的触发顺序可能早于 `on`,
// 若在 once 里 off 监听,可能导致 handleRenderEnd 永远不执行,进而使工具栏按钮不可用。
// 这里统一用 on + 手动自销毁,确保首个 render_end 一定会执行 handleRenderEnd。
let renderHandled = false;
const onRenderEndOnce = () => {
if (renderHandled) return;
renderHandled = true;
try {
handleRenderEnd();
} finally {
instance.off?.("render_end", onRenderEndOnce);
}
};
instance.on?.("render_end", onRenderEndOnce);
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 第三方库节点对象
2026-01-05 06:59:54 +08:00
const renderer = instance.renderer;
if (typeof (node as any)?.active === "function") {
2026-01-02 07:25:50 +08:00
// @ts-expect-error simple-mind-map 节点对象缺少类型
node.active();
} else {
// 兜底:手动维护激活列表
2026-01-02 07:25:50 +08:00
// @ts-expect-error simple-mind-map 渲染器缺少类型
2026-01-05 06:59:54 +08:00
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
2026-01-02 07:25:50 +08:00
// @ts-expect-error simple-mind-map 渲染器缺少类型
2026-01-05 06:59:54 +08:00
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(node, true);
2026-01-02 07:25:50 +08:00
// @ts-expect-error simple-mind-map 渲染器缺少类型
2026-01-05 06:59:54 +08:00
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(node);
}
2026-01-05 06:59:54 +08:00
const list = 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);
});
2026-01-05 06:59:54 +08:00
const renderer = instance.renderer;
const rootNode = getRootNode(instance);
if (rootNode) {
2026-01-05 06:59:54 +08:00
// 初始化时主动标记根节点为选中,确保后续插入子节点有合法的父节点
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(rootNode, true);
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(rootNode);
console.log("[mindmap] init select root", instance.renderer?.activeNodeList?.length ?? 0);
setActiveNodes([rootNode]);
}
2026-01-05 06:59:54 +08:00
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
window.setTimeout(() => {
const root = getRootNode(instance);
if (!root) return;
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(root, true);
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(root);
console.log("[mindmap] delayed select root", instance.renderer?.activeNodeList?.length ?? 0);
setActiveNodes([root]);
}, 120);
if (destroyed) {
instance.destroy();
}
})();
return () => {
destroyed = true;
2026-01-05 06:59:54 +08:00
try {
createdInstance?.destroy();
} catch {
// ignore
}
if (typeof window !== "undefined") {
// @ts-expect-error 调试用全局变量
if (window.__mindmapInstance === createdInstance) {
// @ts-expect-error 调试用全局变量
window.__mindmapInstance = null;
}
}
setMindmap(null);
mindmapReadyRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [block.id]);
2026-01-05 06:59:54 +08:00
const getInstanceCandidate = () =>
mindmap ??
(typeof window !== "undefined"
? // @ts-expect-error 调试用全局变量
(window.__mindmapInstance as MindMapInstance | undefined | null)
: null);
const runWhenReady = (
fn: (mm: MindMapInstance) => void,
retry = 0,
) => {
const instance = getInstanceCandidate();
if (mindmapReadyRef.current && instance) {
fn(instance);
return;
}
if (retry < 60) {
window.setTimeout(() => runWhenReady(fn, retry + 1), 50);
}
};
const execWithReflow = (runner: (mm: MindMapInstance) => void) => {
const attempt = (retry = 0) => {
const instance = getInstanceCandidate();
if (!instance || !mindmapReadyRef.current) {
if (retry < 60) window.setTimeout(() => attempt(retry + 1), 80);
return;
}
const mm = ensureActiveBefore(instance);
if (!mm) return;
runner(mm);
};
attempt();
};
const pickActiveOrRoot = (mm: MindMapInstance) => {
const list = mm.renderer?.activeNodeList;
const last = (mm.renderer as any)?.lastActiveNodeList;
// @ts-expect-error 第三方库类型缺失
return (list && list[0]) || (last && last[0]) || getRootNode(mm) || null;
};
const handleUndo = () => runWhenReady((mm) => mm.execCommand("BACK"));
const handleRedo = () => runWhenReady((mm) => mm.execCommand("FORWARD"));
const handlePainter = () => {
2026-01-05 06:59:54 +08:00
const instance = getInstanceCandidate();
if (!instance?.painter) {
window.alert("格式刷插件未就绪");
return;
}
2026-01-05 06:59:54 +08:00
instance.painter.startPainter();
};
2026-01-05 06:59:54 +08:00
const handleSibling = () =>
execWithReflow((mm) => {
const target = pickActiveOrRoot(mm);
if (!target) return;
const targetUid =
// @ts-expect-error simple-mind-map 节点缺少类型
(typeof (target as any)?.getData === "function"
? (target as any).getData("uid")
: (target as any)?.uid) ?? null;
mm.execCommand?.("SET_NODE_ACTIVE", target, true);
// openEdit=false:保持与 createNewNodeBehavior=activeOnly 的策略一致,避免强制进入编辑模式带来的时序问题
mm.execCommand?.("INSERT_NODE", false, [target]);
// 兜底:某些情况下插入后 activeNodeList 会短暂为空,导致下一步“同级/删除”无目标。
// 这里尝试在下一帧将新插入的节点设为激活(插入同级节点时,新节点位于 target 后)。
window.setTimeout(() => {
try {
const renderer = mm.renderer as any;
if ((renderer?.activeNodeList?.length ?? 0) > 0) return;
if (!targetUid || typeof renderer?.findNodeByUid !== "function") return;
const currentTarget = renderer.findNodeByUid(targetUid);
const parent = currentTarget?.parent;
const siblings: any[] = parent?.children ?? [];
const idx = siblings.indexOf(currentTarget);
const inserted = idx >= 0 ? siblings[idx + 1] : null;
if (!inserted) return;
renderer?.clearActiveNodeList?.();
renderer?.addNodeToActiveList?.(inserted, true);
renderer.lastActiveNodeList = [inserted];
renderer?.emitNodeActiveEvent?.(inserted);
mm.execCommand?.("SET_NODE_ACTIVE", inserted, true);
} catch {
/* ignore */
}
}, 0);
});
const handleChild = () =>
execWithReflow((mm) => {
const parent = pickActiveOrRoot(mm);
if (!parent) return;
const parentUid =
// @ts-expect-error simple-mind-map 节点缺少类型
(typeof (parent as any)?.getData === "function"
? (parent as any).getData("uid")
: (parent as any)?.uid) ?? null;
mm.execCommand?.("SET_NODE_ACTIVE", parent, true);
// openEdit=false:同上,避免插入瞬间强制进入编辑导致异常
mm.execCommand?.("INSERT_CHILD_NODE", false, [parent]);
// 兜底:确保新插入的子节点成为激活节点,保证随后点击“同级节点/删除节点”可用
window.setTimeout(() => {
try {
const renderer = mm.renderer as any;
if ((renderer?.activeNodeList?.length ?? 0) > 0) return;
if (!parentUid || typeof renderer?.findNodeByUid !== "function") return;
const currentParent = renderer.findNodeByUid(parentUid);
const children: any[] = currentParent?.children ?? [];
const inserted = children.length > 0 ? children[children.length - 1] : null;
if (!inserted) return;
renderer?.clearActiveNodeList?.();
renderer?.addNodeToActiveList?.(inserted, true);
renderer.lastActiveNodeList = [inserted];
renderer?.emitNodeActiveEvent?.(inserted);
mm.execCommand?.("SET_NODE_ACTIVE", inserted, true);
} catch {
/* ignore */
}
}, 0);
});
const handleDelete = () => {
2026-01-05 06:59:54 +08:00
runWhenReady((instance) => {
const mm = ensureActiveBefore(instance);
if (!mm) return;
window.setTimeout(() => mm.execCommand("REMOVE_NODE"), 0);
});
};
const handleSummary = () => {
2026-01-05 06:59:54 +08:00
runWhenReady((instance) => {
const mm = ensureActiveBefore(instance);
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
}
});
};
const handleAssociativeLine = () => {
2026-01-05 06:59:54 +08:00
runWhenReady((instance) => {
const mm = ensureActiveBefore(instance);
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
}
});
};
const handleOuterFrame = () => {
2026-01-05 06:59:54 +08:00
runWhenReady((instance) => {
const mm = ensureActiveBefore(instance);
if (mm) {
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
}
});
};
2025-12-31 19:26:38 +08:00
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 = () => {
2026-01-02 07:25:50 +08:00
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
2025-12-31 19:26:38 +08:00
if (first?.getStyle) {
2026-01-02 07:25:50 +08:00
const placement = first.getStyle("imgPlacement", false) as string;
2025-12-31 19:26:38 +08:00
if (placement) setImagePosition(placement);
}
setShowImageModal(true);
};
// 预览:监听 mindmap 事件
useEffect(() => {
if (!mindmap) return;
2026-01-02 07:25:50 +08:00
const handler = (node: MindMapNode, e?: Event) => {
2025-12-31 19:26:38 +08:00
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 = () => {
2026-01-02 07:25:50 +08:00
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
const has = list.some((n) => !!n?.getData?.("image"));
2025-12-31 19:26:38 +08:00
if (!has) setImgToolbarState((s) => ({ ...s, show: false }));
};
2026-01-02 07:25:50 +08:00
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";
2025-12-31 19:26:38 +08:00
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) =>
2026-01-02 07:25:50 +08:00
mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: p }),
2025-12-31 19:26:38 +08:00
);
setImgToolbarState((s) => ({ ...s, placement: p }));
};
2026-01-02 07:25:50 +08:00
const btn = (
p: typeof placement,
Icon: React.ComponentType<{ className?: string }>,
title: string,
) => (
2025-12-31 19:26:38 +08:00
<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 = () => {
2025-12-30 18:39:38 +08:00
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");
};
2025-12-30 18:39:38 +08:00
const handleNoteConfirm = () => {
const note = noteContent.trim();
setShowNoteModal(false);
if (!note) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
};
2025-12-31 19:26:38 +08:00
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 能力占位:后续接入大模型生成/优化节点内容。");
};
2026-01-02 07:25:50 +08:00
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
2026-01-02 07:25:50 +08:00
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);
2026-01-02 07:25:50 +08:00
return;
}
2026-01-02 07:25:50 +08:00
// 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");
};
2026-01-02 07:25:50 +08:00
const handleExport = async (type: string, name = "mindmap") => {
try {
2026-01-02 07:25:50 +08:00
await mindmap?.doExport?.export(type, true, name);
} catch (error) {
console.error(error);
2026-01-02 07:25:50 +08:00
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
}
};
2026-01-02 07:25:50 +08:00
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();
2026-01-02 07:25:50 +08:00
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
}
2026-01-05 06:59:54 +08:00
emitAssetsChanged(docId, undefined, undefined, true);
2026-01-02 07:25:50 +08:00
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,
2026-01-02 07:25:50 +08:00
onDeleteMindmap: handleDeleteMindmap,
onExportJson: handleExportJson,
onExportPng: handleExportPng,
2026-01-02 07:25:50 +08:00
onExportSvg: handleExportSvg,
onExportPdf: handleExportPdf,
onExportMd: handleExportMd,
onExportTxt: handleExportTxt,
onExportXmind: handleExportXmind,
fileInputRef,
};
2025-12-30 18:39:38 +08:00
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">
2025-12-31 19:26:38 +08:00
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-lg font-semibold text-gray-800">备注</span>
2025-12-30 18:39:38 +08:00
<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;
2025-12-31 19:26:38 +08:00
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">
2026-01-02 07:25:50 +08:00
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
2025-12-31 19:26:38 +08:00
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
</div>
2026-01-02 07:25:50 +08:00
{/* eslint-disable-next-line @next/next/no-img-element */}
2025-12-31 19:26:38 +08:00
<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 (
2025-12-30 18:39:38 +08:00
<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">
2026-01-02 07:25:50 +08:00
<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} />
2025-12-31 19:26:38 +08:00
{imgToolbar}
2025-12-30 18:39:38 +08:00
{noteModal}
2025-12-31 19:26:38 +08:00
{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">
2026-01-02 07:25:50 +08:00
<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} />
2025-12-31 19:26:38 +08:00
{imgToolbar}
2025-12-30 18:39:38 +08:00
{noteModal}
2025-12-31 19:26:38 +08:00
{imageModal}
{imageViewer}
</div>
</div>
);
};
export { MindmapBlockView };
export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
2026-01-02 07:25:50 +08:00
docId: { default: "" },
data: { default: defaultMindmapData },
},
content: "none",
},
{
render: (props) => <MindmapBlockView {...props} />,
},
);