"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 { nodeIconList } from "simple-mind-map/src/svg/icons.js"; // @ts-expect-error 第三方库缺少类型定义 import { mergerIconList } from "simple-mind-map/src/utils/index.js"; // 安全获取图片尺寸 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 }; width: number; height: number; miniMap?: unknown; getThemeConfig: (key: string) => unknown; setThemeConfig: (config: unknown) => void; setTheme: (theme: string) => void; setLayout: (layout: string) => void; }; 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(); }; 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; editor: BlockNoteEditor; fullscreen?: boolean; }) => { const containerRef = useRef(null); const fileInputRef = useRef(null); const [mindmap, setMindmap] = useState(null); const [canBack, setCanBack] = useState(false); const [canForward, setCanForward] = useState(false); const [activeNodes, setActiveNodes] = useState([]); const activeCount = activeNodes.length; const [painterMode, setPainterMode] = useState(false); const [showMiniMap, setShowMiniMap] = useState(false); const [activeSidebar, setActiveSidebar] = useState(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 autosaveKey = useMemo(() => `${STORAGE_PREFIX}${block.id}`, [block.id]); const initialDataRef = useRef(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; } } const persistData = useCallback( (data: unknown) => { if (!editor) return; window.localStorage.setItem(autosaveKey, JSON.stringify(data)); editor.updateBlock(block, { props: { ...block.props, data } }); }, [autosaveKey, block, editor], ); const debouncedPersist = useDebouncedCallback((data: unknown) => { persistData(data); }, 500); // 初始选中根节点,后续不强制抢焦点,允许用户自由选择 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 }, ] = 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"), ]); 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 }, ]; plugins.forEach(({ name, plugin }) => { if (!plugin) { console.warn(`思维导图插件加载失败:${name}`); return; } // @ts-expect-error 第三方库缺少类型定义 if (MindMap.hasPlugin(plugin) === -1) { console.log(`注册插件: ${name}`); // @ts-expect-error 第三方库缺少类型定义 MindMap.usePlugin(plugin); } }); const instance = new MindMap({ el: containerRef.current, 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; 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 第三方库节点对象 node.active(); } else { // 兜底:手动维护激活列表 // @ts-expect-error 第三方库缺少类型定义 instance.renderer?.clearActiveNodeList?.(); // @ts-expect-error instance.renderer?.addNodeToActiveList?.(node, true); // @ts-expect-error 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(260); const [imageHeight, setImageHeight] = useState(200); const [imageTitle, setImageTitle] = useState(""); const [imagePosition, setImagePosition] = useState("top"); const fileInputForImage = useRef(null); // 图片预览(双击节点图片) const [showImageViewer, setShowImageViewer] = useState(false); const [viewerSrc, setViewerSrc] = useState(""); const [viewerNode, setViewerNode] = useState(null); const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({}); const [hasActiveImg, setHasActiveImg] = useState(false); const imgToolbarRef = useRef(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 any; 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: any, e: any) => { e?.stopPropagation?.(); e?.preventDefault?.(); const src = node?.nodeData?.data?.image || node?.getData?.("image") || node?.data?.image; if (src) { setViewerSrc(src); setViewerNode(node); 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 || []; const has = list.some((n: any) => !!n?.getData?.("image")); setHasActiveImg(has); 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; 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: any, title: string) => ( ); return (
{ 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, "靠右")}
); }; 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 = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(String(reader.result)); mindmap?.setData(data); mindmap?.command.clearHistory(); debouncedPersist(data); } catch (error) { console.error(error); window.alert("导入失败:文件格式错误"); } finally { event.target.value = ""; } }; reader.readAsText(file, "utf-8"); }; 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 handleExportPng = async () => { try { await mindmap?.doExport?.export("png", true, "mindmap"); } catch (error) { console.error(error); window.alert("导出 PNG 失败,请稍后再试"); } }; const handleSaveAs = () => handleExportJson(); const toolbarProps = { canBack, canForward, activeCount, 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, onExportJson: handleExportJson, onExportPng: handleExportPng, fileInputRef, }; const noteModal = showNoteModal ? (
备注