diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index 29145f87..d610009f 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -105,6 +105,44 @@ export const defaultMindmapData = { children: [], }; +// simple-mind-map 的 RichText 插件初始化会对节点文本做 HTML 转义; +// 若数据里出现 `data.text === undefined`,会在内部调用 `undefined.replace(...)` 直接崩溃。 +// 这里在“保存/恢复”链路上做一次兜底归一化,确保跨视图重建实例时不会白屏。 +const normalizeMindmapData = (input: unknown): unknown => { + if (!input || typeof input !== "object") return defaultMindmapData; + const root = (input as { root?: unknown }).root ?? input; + + const walk = (node: any) => { + if (!node || typeof node !== "object") return; + if (!node.data || typeof node.data !== "object") node.data = {}; + const rawText = (node.data as any).text; + (node.data as any).text = typeof rawText === "string" ? rawText : String(rawText ?? ""); + // 概要(generalization)数据结构里也存在 text 字段,缺失会导致 RichText 初始化崩溃 + const gen = (node.data as any).generalization; + const fixGen = (g: any) => { + if (!g || typeof g !== "object") return; + const t = (g as any).text; + (g as any).text = typeof t === "string" ? t : String(t ?? ""); + }; + if (Array.isArray(gen)) gen.forEach(fixGen); + else fixGen(gen); + if (Array.isArray(node.children)) node.children.forEach(walk); + }; + + walk(root); + return input; +}; + +// 持久化/初始化统一使用“根节点对象”作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map +// 从而触发 RichText 对 wrapper.data 的处理(wrapper.data.text 可能不存在 → htmlEscape 崩溃)。 +const canonicalizeMindmapData = (input: unknown): MindMapData => { + const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as any; + const root = (normalized && typeof normalized === "object" && "root" in normalized) + ? (normalized as any).root + : normalized; + return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData; +}; + const STORAGE_PREFIX = "wolai-mindmap-autosave-"; function downloadJson(data: unknown, name: string) { @@ -310,12 +348,12 @@ const MindmapBlockView = ({ const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null; if (cached) { try { - initialDataRef.current = JSON.parse(cached); + initialDataRef.current = canonicalizeMindmapData(JSON.parse(cached)); } catch { - initialDataRef.current = block.props.data ?? defaultMindmapData; + initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData); } } else { - initialDataRef.current = block.props.data ?? defaultMindmapData; + initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData); } } @@ -332,11 +370,11 @@ const MindmapBlockView = ({ if (!data || cancelled) return; // 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置” if (hasLocalEditsRef.current) return; - initialDataRef.current = data; + initialDataRef.current = canonicalizeMindmapData(data); if (mindmap) { applyingRemoteRef.current = true; try { - mindmap.setData(data); + mindmap.setData(initialDataRef.current); mindmap.command.clearHistory(); } finally { window.setTimeout(() => { @@ -570,14 +608,18 @@ const MindmapBlockView = ({ const persistData = useCallback( (data: unknown) => { if (!editor) return; - window.localStorage.setItem(autosaveKey, JSON.stringify(data)); - editor.updateBlock(block, { props: { ...block.props, data } }); + // 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑 + // 否则切换视图时会用旧数据,表现为“看起来没保存” + const safe = canonicalizeMindmapData(data); + initialDataRef.current = safe; + window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); + editor.updateBlock(block, { props: { ...block.props, data: safe } }); if (docId) { // 同步到本地文件 + Supabase(弱依赖) fetch(`/api/mindmap/${docId}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data }), + body: JSON.stringify({ data: safe }), }) .then((resp) => { if (resp.ok) { @@ -604,7 +646,9 @@ const MindmapBlockView = ({ useEffect(() => { if (!docId || !mindmap || initialSyncDone.current) return; initialSyncDone.current = true; - const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData; + const data = canonicalizeMindmapData( + mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData, + ); (async () => { try { const resp = await fetch(`/api/mindmap/${docId}`, { @@ -662,8 +706,18 @@ const MindmapBlockView = ({ let destroyed = false; let createdInstance: MindMapInstance | null = null; (async () => { - if (!containerRef.current) return; - const hostContainer = containerRef.current; + // 进入/退出全屏会切换渲染树,ref 在某些时序下可能短暂为 null。 + // 若这里直接 return,会导致新实例永远不创建,表现为“切换视图后空白/像没保存”。 + const waitForContainer = async (): Promise => { + for (let i = 0; i < 60; i += 1) { + if (destroyed) return null; + if (containerRef.current) return containerRef.current; + await new Promise((r) => window.setTimeout(r, 50)); + } + return containerRef.current; + }; + const hostContainer = await waitForContainer(); + if (!hostContainer) return; const [ { default: MindMap }, { default: Painter }, @@ -819,6 +873,29 @@ const MindmapBlockView = ({ hostEl.innerHTML = ""; } + // 全屏/非全屏切换会重建实例:创建新实例前优先从本地缓存读取最新数据 + // 以避免“全屏里编辑 → 退出全屏后内容消失 / 反之亦然”。 + let dataForInitSource = "unknown"; + const rawForInit = (() => { + if (typeof window === "undefined") { + dataForInitSource = "ssr-fallback"; + return block.props.data ?? initialDataRef.current ?? defaultMindmapData; + } + try { + const cached = window.localStorage.getItem(autosaveKey); + if (cached) { + dataForInitSource = "localStorage"; + return JSON.parse(cached); + } + } catch { + // ignore + } + dataForInitSource = block.props.data ? "block.props.data" : "initialDataRef"; + return block.props.data ?? initialDataRef.current ?? defaultMindmapData; + })(); + const dataForInit = canonicalizeMindmapData(rawForInit); + initialDataRef.current = dataForInit; + type MindMapConstructor = new (options: { el: HTMLElement; data: unknown; @@ -836,7 +913,7 @@ const MindmapBlockView = ({ const instance = new MindMapCtor({ el: hostEl, - data: initialDataRef.current, + data: dataForInit, theme: "classic", layout: "logicalStructure", mousewheelAction: "zoom", @@ -964,11 +1041,16 @@ const MindmapBlockView = ({ }); instance.on?.("painter_start", () => setPainterMode(true)); instance.on?.("painter_end", () => setPainterMode(false)); - instance.on?.("data_change", (data: unknown) => { + instance.on?.("data_change", () => { if (!applyingRemoteRef.current) { hasLocalEditsRef.current = true; } - debouncedPersist(data); + // data_change 的参数在部分场景下可能不是“可直接序列化的完整数据” + //(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。 + // 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。 + const snapshot = + instance.getData?.(true) ?? instance.getData?.() ?? null; + if (snapshot) debouncedPersist(snapshot); }); const renderer = instance.renderer; const rootNode = getRootNode(instance); @@ -1002,13 +1084,15 @@ const MindmapBlockView = ({ const data = createdInstance.getData?.(true) ?? createdInstance.getData?.(); if (data) { + const safe = canonicalizeMindmapData(data); + initialDataRef.current = safe; try { - window.localStorage.setItem(autosaveKey, JSON.stringify(data)); + window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); } catch { // ignore } try { - editor?.updateBlock(block, { props: { ...block.props, data } }); + editor?.updateBlock(block, { props: { ...block.props, data: safe } }); } catch { // ignore } @@ -1016,7 +1100,7 @@ const MindmapBlockView = ({ fetch(`/api/mindmap/${docId}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ data }), + body: JSON.stringify({ data: safe }), }).catch(() => { // ignore });