From 7480f58c6e7a968cb4c10dd793f59461703f4f32 Mon Sep 17 00:00:00 2001 From: liaibo Date: Mon, 5 Jan 2026 06:59:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=80=9D=E7=BB=B4=E5=AF=BC=E5=9B=BE=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wolai-frontend/.gitignore | 4 +- .../components/editor/blocks/MindmapBlock.tsx | 523 +++++++++++++++--- .../editor/blocks/MindmapToolbar.tsx | 26 +- 3 files changed, 475 insertions(+), 78 deletions(-) diff --git a/wolai-frontend/.gitignore b/wolai-frontend/.gitignore index 4c2970ab..2b84e14d 100644 --- a/wolai-frontend/.gitignore +++ b/wolai-frontend/.gitignore @@ -41,4 +41,6 @@ yarn-error.log* next-env.d.ts #test -**/test/ \ No newline at end of file +**/test/ +pw-tests/** +pw-tests/ \ No newline at end of file diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index 5e4819e3..d7163c60 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input"; import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react"; import iconConfig from "./mindmapIconConfig"; import { emitAssetsChanged } from "@/lib/events"; +import { useEditorBridgeStore } from "@/store/editor-bridge"; // 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document // @ts-expect-error 第三方库缺少类型定义 @@ -100,7 +101,8 @@ 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]; + const candidates = [(svgModule as any).Element, (svgModule as any).G]; + let warned = false; candidates.forEach((Ctor) => { if (!Ctor?.prototype) return; if (Ctor.prototype.__wolaiRboxPatched) return; @@ -111,17 +113,45 @@ const patchSvgRbox = async () => { 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, - }; + // 退化到 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; } }; Ctor.prototype.__wolaiRboxPatched = true; @@ -136,18 +166,22 @@ const applyToActiveNodes = ( window.alert("思维导图尚未初始化"); return; } - const list = mindmap.renderer?.activeNodeList; + const renderer = mindmap.renderer; + const list = 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); + // 优先尝试 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; } } - (mindmap.renderer?.activeNodeList ?? []).forEach(handler); + (renderer?.activeNodeList ?? []).forEach(handler); }; // 确保存在激活节点后再执行命令,若无则自动选中根节点 @@ -157,11 +191,21 @@ const ensureActiveBefore = (mindmap?: MindMapInstance | null) => { window.alert("思维导图尚未初始化"); return null; } - const list = mm.renderer?.activeNodeList ?? []; + const renderer = mm.renderer; + const list = 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); + 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; @@ -179,9 +223,13 @@ const MindmapBlockView = ({ editor: BlockNoteEditor; fullscreen?: boolean; }) => { + const currentDocumentId = useEditorBridgeStore( + (state) => state.currentDocumentId, + ); const containerRef = useRef(null); const fileInputRef = useRef(null); const [mindmap, setMindmap] = useState(null); + const mindmapReadyRef = useRef(false); const [canBack, setCanBack] = useState(false); const [canForward, setCanForward] = useState(false); const [activeNodes, setActiveNodes] = useState([]); @@ -199,10 +247,11 @@ const MindmapBlockView = ({ const docId = useMemo( () => block.props.docId || + currentDocumentId || (typeof window !== "undefined" ? window.location.pathname.split("/").pop() ?? "" : ""), - [block.props.docId], + [block.props.docId, currentDocumentId], ); const autosaveKey = useMemo( @@ -262,7 +311,13 @@ const MindmapBlockView = ({ }) .then((resp) => { if (resp.ok) { - emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" }); + emitAssetsChanged(docId, { + id: `mindmap-${docId}`, + document_id: docId, + asset_type: "mindmap", + file_name: "mindmap.json", + file_url: `/documents/${docId}`, + }); } }) .catch((err) => console.warn("思维导图同步失败", err)); @@ -276,21 +331,53 @@ const MindmapBlockView = ({ }, 500); const initialSyncDone = useRef(false); + // 若渲染结束后激活列表被清空,定期兜底恢复根节点为选中,避免后续指令找不到父节点 + useEffect(() => { + if (!mindmap) return; + const timer = window.setInterval(() => { + const renderer = mindmap.renderer; + if (!renderer) return; + if (!renderer.activeNodeList || renderer.activeNodeList.length === 0) { + const root = getRootNode(mindmap); + if (root && renderer.addNodeToActiveList) { + renderer.addNodeToActiveList(root, true); + renderer.emitNodeActiveEvent?.(root); + } + } + }, 200); + return () => window.clearInterval(timer); + }, [getRootNode, mindmap]); 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" }); + (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(() => ""), + ); } - }) - .catch((err) => console.warn("初次创建思维导图文件失败", err)); + } 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}`, + }); + } + })(); }, [docId, mindmap, initialDataRef]); // 初始选中根节点,后续不强制抢焦点,允许用户自由选择 @@ -306,10 +393,24 @@ const MindmapBlockView = ({ } }, [mindmap, activeNodes.length]); + // 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; + let createdInstance: MindMapInstance | null = null; (async () => { if (!containerRef.current) return; + const hostContainer = containerRef.current; const [ { default: MindMap }, { default: Painter }, @@ -333,6 +434,7 @@ const MindmapBlockView = ({ { default: NodeBase64ImageStorage }, { default: ExportPDF }, { default: ExportXMind }, + MindMapNodeModule, ] = await Promise.all([ import("simple-mind-map"), import("simple-mind-map/src/plugins/Painter.js"), @@ -353,11 +455,52 @@ const MindmapBlockView = ({ 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/NodeBase64ImageStorage.js"), import("simple-mind-map/src/plugins/ExportPDF.js"), import("simple-mind-map/src/plugins/ExportXMind.js"), + import("simple-mind-map/src/core/render/node/MindMapNode.js"), ]); + // 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 }, @@ -403,7 +546,15 @@ const MindmapBlockView = ({ await patchSvgRbox(); - const hostEl = containerRef.current ?? document.body; + if (destroyed) return; + + const hostEl = hostContainer; + // 防止同一容器残留旧实例的 svg/dom(StrictMode 或异常 destroy 场景) + try { + hostEl.replaceChildren(); + } catch { + hostEl.innerHTML = ""; + } const instance = new MindMap({ el: hostEl, @@ -415,13 +566,19 @@ const MindmapBlockView = ({ enableCtrlKeyNodeSelection: true, fit: true, useLeftKeySelectionRightKeyDrag: true, + // 新建节点默认激活,编辑由我们手动触发,避免初次插入时定位到 (0,0) + createNewNodeBehavior: "activeOnly", // 传入扩展图标表,和官方一致 iconList: mergerIconList([ ...nodeIconList, ...(iconConfig as unknown[]), ]), }) as MindMapInstance; - // 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针 + createdInstance = instance; + // 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用 + //(render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。 + mindmapReadyRef.current = true; + // 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针 if (!(instance as any).commonCaches) { (instance as any).commonCaches = {}; } @@ -433,6 +590,38 @@ const MindmapBlockView = ({ (hostEl ?? document.body).appendChild(measureDiv); } instance.setMode?.("edit"); + 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") { // 便于开发阶段在控制台直接调试实例 @@ -446,26 +635,55 @@ const MindmapBlockView = ({ setCanBack(index > 0); setCanForward(index < len - 1); }); - instance.on?.("node_active", (_node: unknown, list: unknown[]) => { + // 渲染完成后再二次居中,并确保根节点被标记为激活 + 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 第三方库节点对象 - if (typeof node?.active === "function") { + const renderer = instance.renderer; + if (typeof (node as any)?.active === "function") { // @ts-expect-error simple-mind-map 节点对象缺少类型 node.active(); } else { // 兜底:手动维护激活列表 // @ts-expect-error simple-mind-map 渲染器缺少类型 - instance.renderer?.clearActiveNodeList?.(); + renderer?.clearActiveNodeList && renderer.clearActiveNodeList(); // @ts-expect-error simple-mind-map 渲染器缺少类型 - instance.renderer?.addNodeToActiveList?.(node, true); + renderer?.addNodeToActiveList && renderer.addNodeToActiveList(node, true); // @ts-expect-error simple-mind-map 渲染器缺少类型 - instance.renderer?.emitNodeActiveEvent?.(node); + renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(node); } - const list = instance.renderer?.activeNodeList ?? []; + const list = renderer?.activeNodeList ?? []; setActiveNodes(list.length === 0 && node ? [node] : (list as unknown[])); }); instance.on?.("painter_start", () => setPainterMode(true)); @@ -473,10 +691,26 @@ const MindmapBlockView = ({ instance.on?.("data_change", (data: unknown) => { debouncedPersist(data); }); + const renderer = instance.renderer; const rootNode = getRootNode(instance); if (rootNode) { + // 初始化时主动标记根节点为选中,确保后续插入子节点有合法的父节点 + 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]); } + // 若首次渲染时 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(); @@ -485,52 +719,193 @@ const MindmapBlockView = ({ return () => { destroyed = true; - setMindmap((prev) => { - prev?.destroy(); - return null; - }); + 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]); - const handleUndo = () => mindmap?.execCommand("BACK"); - const handleRedo = () => mindmap?.execCommand("FORWARD"); + 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 recenterIfNeeded = (mm?: MindMapInstance | null) => { + try { + const instance = mm ?? getInstanceCandidate(); + const renderer = instance?.renderer; + const view = instance?.view; + renderer?.setRootNodeCenter && renderer.setRootNodeCenter(); + view?.fit && view.fit(); + } catch { + /* 空 */ + } + }; + 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; + try { + const renderer = mm.renderer as any; + if (renderer?.refresh) renderer.refresh(); + if (renderer?.reRender) renderer.reRender(); + recenterIfNeeded(mm); + } catch { + /* 空 */ + } + 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 = () => { - if (!mindmap?.painter) { + const instance = getInstanceCandidate(); + if (!instance?.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); + instance.painter.startPainter(); }; + 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 = () => { - if (!mindmap) return; - window.setTimeout(() => mindmap.execCommand("REMOVE_NODE"), 0); + runWhenReady((instance) => { + const mm = ensureActiveBefore(instance); + if (!mm) return; + window.setTimeout(() => mm.execCommand("REMOVE_NODE"), 0); + }); }; const handleSummary = () => { - const mm = ensureActiveBefore(mindmap); - if (mm) { - window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0); - } + runWhenReady((instance) => { + const mm = ensureActiveBefore(instance); + 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); - } + runWhenReady((instance) => { + const mm = ensureActiveBefore(instance); + 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); - } + runWhenReady((instance) => { + const mm = ensureActiveBefore(instance); + if (mm) { + window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0); + } + }); }; const [showImageModal, setShowImageModal] = useState(false); @@ -865,7 +1240,7 @@ const MindmapBlockView = ({ } catch { // ignore } - emitAssetsChanged(docId); + emitAssetsChanged(docId, undefined, undefined, true); editor.removeBlocks([block.id]); }, [autosaveKey, block.id, docId, editor]); diff --git a/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx b/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx index e583956c..45bbe485 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapToolbar.tsx @@ -8,6 +8,11 @@ import { type NodeToolbarKey, } from "./mindmapToolbarConfig"; +const stopEditorEvent = (e: React.SyntheticEvent) => { + e.preventDefault(); + e.stopPropagation(); +}; + type ToolbarProps = { canBack: boolean; canForward: boolean; @@ -62,7 +67,13 @@ const ToolbarButton = ({ }) => (