"use client"; // 说明:本文件仅保留为 legacy/compat/reference 实现,用于独立导图页、 // 历史 BlockNote 导图块和 blob import/export 对照;3000 文档页 Phase 6 // 默认主链是 leptos-tiptap NodeView + leptos-mindmap adapter。 import "simple-mind-map/dist/simpleMindMap.esm.css"; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { createReactBlockSpec } from "@blocknote/react"; import type { BlockConfig, BlockNoteEditor, DefaultInlineContentSchema, DefaultStyleSchema, PropSchema, SpecificBlock, } from "@blocknote/core"; 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 { MindmapContextMenu } from "./MindmapContextMenu"; import type { SidebarPanel } from "./mindmapSidebarConfig"; import type { MindMapNode } from "./mindmapTypes"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react"; import iconConfig from "./mindmapIconConfig"; import { emitAssetsChanged } from "@/lib/events"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { api } from "@/lib/convex/api"; import { useQuery } from "convex/react"; import { buildMindmapProjection, canonicalizeMindmapData, defaultMindmapData, extractMindmapTitle, type MindMapData, type MindmapProjection, type MindmapRouteMeta, } from "@/lib/mindmap/mindmap-projection"; // 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document const loadIconModules = async () => { const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js"); const { mergerIconList } = await import("simple-mind-map/src/utils/index.js"); return { nodeIconList, mergerIconList }; }; // 安全获取图片尺寸 const getImageSizeSafe = (url: string): Promise<{ width: number; height: number } | null> => new Promise((resolve) => { if (!url) return resolve(null); const img = new Image(); img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.onerror = () => resolve(null); img.src = url; }); // 类型检查辅助函数 const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); const readInitialRequestMeta = (meta: unknown): { requestId: string; traceId: string } | null => { if (!isRecord(meta)) return null; const requestId = typeof meta.requestId === "string" ? meta.requestId.trim() : ""; const traceId = typeof meta.traceId === "string" ? meta.traceId.trim() : ""; if (!requestId || !traceId) return null; return { requestId, traceId }; }; const readInitialWorkspaceId = (meta: unknown): string => { if (!isRecord(meta)) return ""; return typeof meta.workspaceId === "string" ? meta.workspaceId.trim() : ""; }; type MindMapInstance = { execCommand: (command: string, ...args: unknown[]) => void; destroy: () => void; setData: (data: unknown) => void; getData: (withConfig?: boolean) => unknown; on?: (event: string, handler: (...args: TArgs) => void) => void; emit?: (event: string, ...args: unknown[]) => void; off?: (event: string, handler: (...args: TArgs) => void) => void; setMode?: (mode: string) => void; command: { clearHistory: () => void }; editNodeClassList?: string[]; view: { fit: () => void; scale: number; enlarge: () => void; narrow: () => void; setScale: (scale: number, cx: number, cy: number) => void; }; renderer: { activeNodeList: unknown[]; lastActiveNodeList?: unknown[]; root?: unknown; renderTree: { _node: unknown }; setRootNodeCenter?: () => void; clearActiveNodeList?: () => void; addNodeToActiveList?: (node: unknown, isActive?: boolean) => void; emitNodeActiveEvent?: (node: unknown) => void; findNodeByUid?: (uid: string) => unknown; copy?: () => void; paste?: () => void; beingCopyData?: unknown; }; 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; }; type MindmapDocumentBridge = { updateBlockData?: (data: unknown) => void; removeBlock?: () => void; }; type MindmapPreviewCardProps = { docId?: string; mindmapId: string; title: string; projection: MindmapProjection | null; onOpenInline: () => void; onOpenFullscreen: () => void; onOpenStandalone: () => void; }; type MindmapSurfaceViewProps = { docId?: string; mindmapId: string; initialData?: unknown; initialProjection?: MindmapProjection | null; standalone?: boolean; fullscreen?: boolean; onExitFullscreen?: () => void; documentBridge?: MindmapDocumentBridge | null; }; const MindmapPreviewCard = ({ docId, mindmapId, title, projection, onOpenInline, onOpenFullscreen, onOpenStandalone, }: MindmapPreviewCardProps) => { const previewNodes = projection?.nodes.slice(0, 6) ?? []; return (
文档内嵌导图
{title}
默认只加载子树投影预览,进入编辑或沉浸态后再挂载导图内核
{projection?.nodeCount ?? 1} 个节点 {projection?.projection ?? "mindmap_subtree"} 按需挂载编辑器
{previewNodes.length > 0 ? ( previewNodes.map((node) => (
{node.text}
子节点 {node.childCount}
)) ) : (
当前还没有可预览的子树摘要,进入编辑态后可继续修改。
)}
); }; // 将思维导图数据中的 asset:id 格式转换为实际的签名 URL // 返回转换后的数据和 signed URL -> asset ID 的映射 const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData; urlToAssetId: Map }> => { const assetIds: string[] = []; // 递归收集所有 asset:id const collectAssetIds = (node: any) => { if (!node) return; const candidates: unknown[] = [ node?.data?.image, node?.image, node?.image?.url, node?.data?.image?.url, ]; candidates.forEach((value) => { if (typeof value !== "string") return; if (!value.startsWith("asset:")) return; const id = value.replace(/^asset:/, "").trim(); if (id) assetIds.push(id); }); if (Array.isArray(node.children)) { node.children.forEach(collectAssetIds); } }; collectAssetIds(data); // 如果没有 asset:id,直接返回原数据和空映射 if (assetIds.length === 0) return { data, urlToAssetId: new Map() }; // 批量获取签名 URL const urlMap = new Map(); await Promise.all(assetIds.map(async (id) => { try { const response = await fetch(`/api/media/sign?assetId=${id}`); if (response.ok) { const result = await response.json(); urlMap.set(id, result.signedUrl); } } catch {} })); // 递归替换 asset:id 为签名 URL,并建立反向映射 const urlToAssetId = new Map(); const replaceAssetIds = (node: any): any => { if (!node) return node; const newNode = { ...node }; const replaceAssetString = (value: unknown): string | null => { if (typeof value !== "string") return null; if (!value.startsWith("asset:")) return null; const id = value.replace(/^asset:/, "").trim(); if (!id) return null; const signedUrl = urlMap.get(id); if (signedUrl) { urlToAssetId.set(signedUrl, id); return signedUrl; } // 即使签名失败,也保留 asset:id -> id 的映射,方便后续删除/撤销逻辑使用 urlToAssetId.set(`asset:${id}`, id); return `asset:${id}`; }; // 常见:node.data.image = "asset:xxx" const nextDataImage = replaceAssetString(newNode?.data?.image); if (nextDataImage && newNode.data && typeof newNode.data === "object") { newNode.data = { ...newNode.data, image: nextDataImage }; } // 兼容:node.image = "asset:xxx" if (typeof newNode.image === "string") { const nextImage = replaceAssetString(newNode.image); if (nextImage) newNode.image = nextImage; } // 兼容:node.image.url = "asset:xxx" const nextImageUrl = replaceAssetString(newNode?.image?.url); if (nextImageUrl && newNode.image && typeof newNode.image === "object") { newNode.image = { ...newNode.image, url: nextImageUrl }; } // 兼容:node.data.image.url = "asset:xxx" const nextDataImageUrl = replaceAssetString(newNode?.data?.image?.url); if (nextDataImageUrl && newNode.data && typeof newNode.data === "object") { const dataImage = (newNode.data as any).image; if (dataImage && typeof dataImage === "object") { (newNode.data as any).image = { ...(dataImage as any), url: nextDataImageUrl }; } } if (Array.isArray(newNode.children)) { newNode.children = newNode.children.map(replaceAssetIds); } return newNode; }; return { data: replaceAssetIds(data), urlToAssetId }; }; // 将思维导图数据中的签名 URL 转换回 asset:id 格式(用于保存) const revertToAssetIds = (data: MindMapData, urlToAssetId: Map): MindMapData => { const revertNode = (node: any): any => { if (!node) return node; const newNode = { ...node }; const revertSigned = (value: unknown): string | null => { if (typeof value !== "string") return null; const assetId = urlToAssetId.get(value); if (!assetId) return null; return `asset:${assetId}`; }; // 常见:node.data.image = signedUrl const nextDataImage = revertSigned(newNode?.data?.image); if (nextDataImage && newNode.data && typeof newNode.data === "object") { newNode.data = { ...newNode.data, image: nextDataImage }; } // 兼容:node.image = signedUrl if (typeof newNode.image === "string") { const nextImage = revertSigned(newNode.image); if (nextImage) newNode.image = nextImage; } // 兼容:node.image.url = signedUrl const nextImageUrl = revertSigned(newNode?.image?.url); if (nextImageUrl && newNode.image && typeof newNode.image === "object") { newNode.image = { ...newNode.image, url: nextImageUrl }; } // 兼容:node.data.image.url = signedUrl const nextDataImageUrl = revertSigned(newNode?.data?.image?.url); if (nextDataImageUrl && newNode.data && typeof newNode.data === "object") { const dataImage = (newNode.data as any).image; if (dataImage && typeof dataImage === "object") { (newNode.data as any).image = { ...(dataImage as any), url: nextDataImageUrl }; } } if (Array.isArray(newNode.children)) { newNode.children = newNode.children.map(revertNode); } return newNode; }; return revertNode(data); }; const STORAGE_PREFIX = "wolai-mindmap-autosave-"; function downloadJson(data: unknown, name: string) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${name}.json`; anchor.click(); URL.revokeObjectURL(url); } const createSimplePrompt = (title: string, placeholder = "") => { const value = window.prompt(title, placeholder); if (!value || !value.trim()) return null; return value.trim(); }; // 修补 svg.js rbox 在节点未挂载时抛出的异常 const patchSvgRbox = async () => { // 仅在浏览器环境生效 if (typeof window === "undefined") return; const svgModule = await import("@svgdotjs/svg.js"); type SvgCtorPrototype = { rbox?: (ref?: unknown) => unknown; __wolaiRboxPatched?: boolean; }; type SvgCtor = { prototype?: SvgCtorPrototype } | undefined; const svgModuleTyped = svgModule as unknown as { Element?: SvgCtor; G?: SvgCtor }; const candidates = [svgModuleTyped.Element, svgModuleTyped.G]; let warned = false; candidates.forEach((Ctor) => { if (!Ctor?.prototype) return; if (Ctor.prototype.__wolaiRboxPatched) return; const original = Ctor.prototype.rbox; if (typeof original !== "function") return; Ctor.prototype.rbox = function patchedRbox(ref?: unknown) { try { return original.call(this, ref); } catch (error) { // 退化到 DOM 的 getBoundingClientRect 计算,避免初次渲染时出现 (0,0) type SvgElementLike = { node?: Element | null; el?: Element | null }; const maybeThis = this as unknown as SvgElementLike; const el = maybeThis?.node ?? maybeThis?.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) warned = true; return fallback; } }; Ctor.prototype.__wolaiRboxPatched = true; }); }; const applyToActiveNodes = ( mindmap: MindMapInstance | null, handler: (node: unknown) => void, ) => { if (!mindmap) { window.alert("思维导图尚未初始化"); return; } const renderer = mindmap.renderer; const list = renderer?.activeNodeList; if (!list || list.length === 0) { // 优先尝试 lastActiveNodeList(部分操作会短暂清空 activeNodeList) const lastActive = renderer?.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; } } (renderer?.activeNodeList ?? []).forEach(handler); }; // 确保存在激活节点后再执行命令,若无则自动选中根节点 const ensureActiveBefore = (mindmap?: MindMapInstance | null) => { const mm = mindmap ?? null; if (!mm) { window.alert("思维导图尚未初始化"); return null; } const renderer = mm.renderer; const list = renderer?.activeNodeList ?? []; if (!list || list.length === 0) { const lastActive = renderer?.lastActiveNodeList?.[0]; const root = renderer?.root ?? renderer?.renderTree?._node; const fallback = lastActive ?? root; if (fallback) { // 主动维护激活列表,避免首次插入子节点时因无激活节点导致插入位置错误 // 先确保根节点居中,避免后续插入节点跑到 (0,0) // 注意:setRootNodeCenter 内部依赖 renderer.root(而不是 renderTree._node)。 // 在“刚初始化/刚插入第二张导图”的窗口期里 renderer.root 可能仍是 null, // 此时调用会触发内部解构异常,导致快捷键/工具栏命令中断。 if (renderer?.root && renderer?.setRootNodeCenter) { try { renderer.setRootNodeCenter(); } catch { // ignore } } mm.view?.fit?.(); if (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 MindmapSurfaceView = ({ docId: docIdProp = "", mindmapId, initialData, initialProjection = null, standalone = false, fullscreen = false, onExitFullscreen, documentBridge = null, }: MindmapSurfaceViewProps) => { const containerRef = useRef(null); const fileInputRef = useRef(null); const [mindmap, setMindmap] = useState(null); const mindmapReadyRef = useRef(false); const mindmapRef = useRef(null); const hasLocalEditsRef = useRef(false); const lastLocalEditAtRef = useRef(0); const markLocalEdited = useCallback(() => { hasLocalEditsRef.current = true; lastLocalEditAtRef.current = Date.now(); }, []); const applyingRemoteRef = useRef(false); const [canBack, setCanBack] = useState(false); const [canForward, setCanForward] = useState(false); const [activeNodes, setActiveNodes] = useState([]); 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 [localFullscreen, setLocalFullscreen] = useState(false); const [showEmbedToolbar, setShowEmbedToolbar] = useState(false); const effectiveFullscreen = fullscreen || localFullscreen; const wrapperRef = useRef(null); const hotkeyScopeRef = useRef(false); const lastInteractionAtRef = useRef(0); const skipNextPasteRef = useRef(false); const persistDataRef = useRef<((data: unknown) => void) | null>(null); const recentNodeDblclickRef = useRef(false); const deletingRef = useRef(false); const textEditOpenRef = useRef(false); const suppressTextEditRefocusUntilRef = useRef(0); const lastTextEditRefocusAtRef = useRef(0); const pendingInitActivateRootRef = useRef(false); const pendingRenderEndActivateRootRef = useRef(false); // 存储 signed URL 到 asset:id 的映射,用于保存时转换回 asset:id 格式 const signedUrlToAssetIdRef = useRef(new Map()); // 存储当前思维导图中使用的所有图片 URL,用于检测图片删除 const currentImageUrlsRef = useRef(new Set()); // 存储已删除的 asset ID,用于撤销时恢复 const deletedAssetIdsRef = useRef(new Set()); const getRootNode = useCallback((mm?: MindMapInstance | null) => { const instance = mm ?? mindmap; return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null; }, [mindmap]); const docId = useMemo(() => docIdProp || "", [docIdProp]); const pageId = docId; const attachmentId = mindmapId; const projectionData = initialProjection?.data ?? null; const [requestMeta, setRequestMeta] = useState<{ requestId: string; traceId: string } | null>( () => readInitialRequestMeta(initialProjection?.meta), ); const [titleText, setTitleText] = useState(() => initialProjection?.title ?? extractMindmapTitle(projectionData ?? initialData ?? defaultMindmapData), ); const syncTitleText = useCallback((data: unknown) => { setTitleText(extractMindmapTitle(data)); }, []); const syncMindmapRouteMeta = useCallback((meta: unknown) => { if (!isRecord(meta)) return; const requestId = typeof meta.requestId === "string" ? meta.requestId.trim() : ""; const traceId = typeof meta.traceId === "string" ? meta.traceId.trim() : ""; if (requestId && traceId) { setRequestMeta((prev) => prev?.requestId === requestId && prev?.traceId === traceId ? prev : { requestId, traceId }, ); } const nextWorkspaceId = typeof meta.workspaceId === "string" ? meta.workspaceId.trim() : ""; if (nextWorkspaceId) { setWorkspaceId((prev) => (prev === nextWorkspaceId ? prev : nextWorkspaceId)); } }, []); // 获取 workspaceId(用于上传图片) useEffect(() => { let cancelled = false; if (!docId) return; void (async () => { try { // 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。 const res = await fetch( `/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`, ); const json: unknown = await res.json().catch(() => null); if (!res.ok) return; if (cancelled) return; const obj = isRecord(json) ? json : {}; setWorkspaceId(String(obj.workspaceId ?? "")); } catch { // ignore } })(); return () => { cancelled = true; }; }, [docId]); useEffect(() => { hasLocalEditsRef.current = false; lastLocalEditAtRef.current = 0; applyingRemoteRef.current = false; }, [docId]); const autosaveKey = useMemo(() => { if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`; return `${STORAGE_PREFIX}${mindmapId}`; }, [docId, mindmapId]); // 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。 const lastLocalSavedAtRef = useRef(null); const lastAppliedRemoteUpdatedAtRef = useRef(null); const remoteMindmap = useQuery( api.mindmaps.get, isConvexEnabled() && docId ? { docId, mindmapId } : "skip", ); const initialDataRef = useRef(null); if (initialDataRef.current === null) { const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null; if (cached) { try { initialDataRef.current = canonicalizeMindmapData(JSON.parse(cached)); } catch { initialDataRef.current = canonicalizeMindmapData(projectionData ?? initialData ?? defaultMindmapData); } } else { initialDataRef.current = canonicalizeMindmapData(projectionData ?? initialData ?? defaultMindmapData); } } useEffect(() => { syncTitleText(initialDataRef.current ?? projectionData ?? initialData ?? defaultMindmapData); }, [autosaveKey, initialData, projectionData, syncTitleText]); useEffect(() => { if (!initialProjection?.meta) return; syncMindmapRouteMeta(initialProjection.meta); }, [initialProjection, syncMindmapRouteMeta]); // 优先加载本地文件,其次 Supabase(通过后端 API) useEffect(() => { let cancelled = false; if (!docId) return; (async () => { try { // 多导图:按 docId + mindmapId 拉取 const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`); if (!resp.ok) return; const payload = await resp.json().catch(() => null); const data = payload?.data; if (!data || cancelled) return; syncMindmapRouteMeta(payload?.meta); // 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置” if (hasLocalEditsRef.current) return; initialDataRef.current = canonicalizeMindmapData(data); syncTitleText(initialDataRef.current); if (mindmap) { applyingRemoteRef.current = true; try { mindmap.setData(initialDataRef.current); mindmap.command.clearHistory(); } finally { window.setTimeout(() => { applyingRemoteRef.current = false; }, 0); } } } catch {} })(); return () => { cancelled = true; }; }, [docId, mindmap, mindmapId, syncMindmapRouteMeta, syncTitleText]); // Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。 useEffect(() => { if (!remoteMindmap || typeof remoteMindmap !== "object") return; const meta = (remoteMindmap as any).meta as Record | undefined; const deletedAt = typeof meta?.deleted_at === "string" ? (meta.deleted_at as string) : null; const updatedAt = typeof meta?.updated_at === "string" ? (meta.updated_at as string) : null; if (deletedAt) { if (!docId || deletingRef.current) return; deletingRef.current = true; try { window.localStorage.removeItem(autosaveKey); } catch { // ignore } // 全屏页:退出到文档页 if (effectiveFullscreen && typeof onExitFullscreen === "function") { window.alert("该思维导图已被删除(已移入垃圾桶),将返回页面。"); onExitFullscreen(); return; } // 嵌入编辑器:复用编辑器监听链路移除块 emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]); return; } if (!updatedAt) return; if (lastAppliedRemoteUpdatedAtRef.current === updatedAt) return; // 如果这是本端刚刚保存产生的回放,跳过应用,避免清空历史/打断输入 if (lastLocalSavedAtRef.current && lastLocalSavedAtRef.current === updatedAt) { lastAppliedRemoteUpdatedAtRef.current = updatedAt; return; } // 本地仍有未同步编辑时,不覆盖 if (hasLocalEditsRef.current || deletingRef.current) return; const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData); lastAppliedRemoteUpdatedAtRef.current = updatedAt; initialDataRef.current = incoming; syncTitleText(incoming); try { window.localStorage.setItem(autosaveKey, JSON.stringify(incoming)); } catch { // ignore } if (!effectiveFullscreen && !standalone) { try { documentBridge?.updateBlockData?.(incoming); } catch { // ignore } } if (mindmap) { applyingRemoteRef.current = true; try { mindmap.setData(incoming); mindmap.command.clearHistory(); } finally { window.setTimeout(() => { applyingRemoteRef.current = false; }, 0); } } }, [ autosaveKey, docId, effectiveFullscreen, mindmap, mindmapId, onExitFullscreen, remoteMindmap, standalone, documentBridge, syncTitleText, ]); // 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域 useEffect(() => { const onPointerDownCapture = (e: Event) => { const wrapper = wrapperRef.current; if (!wrapper) return; const targetNode = e.target as Node | null; const inWrapper = !!(targetNode && wrapper.contains(targetNode)); hotkeyScopeRef.current = inWrapper; lastInteractionAtRef.current = inWrapper ? Date.now() : 0; // 关键:simple-mind-map 的画布/节点有时会 stopPropagation,导致外层 onPointerDown // 不触发,从而无法把焦点从 BlockNote 编辑器移到思维导图块上,最终表现为 Enter/Tab // 等快捷键不生效。 // 这里用 capture 阶段兜底:只要点击发生在 wrapper 内,就把焦点拉到 wrapper 上。 if (!inWrapper) return; const target = (targetNode as HTMLElement | null) ?? null; if (target) { const tag = target.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; // 节点文本编辑中不抢焦点,避免影响输入/粘贴(仅判断 simple-mind-map 的节点编辑元素) const mm = mindmapRef.current; const editClasses = mm?.editNodeClassList; if (editClasses) { for (const cls of editClasses) { if (!cls) continue; if (target.classList?.contains(cls)) return; const found = target.closest?.(`.${cls}`); if (found) return; } } } // 这里用 setTimeout(0) 而不是 microtask:BlockNote/ProseMirror 可能会在同一轮事件里重新抢回焦点, // 导致“选中节点后 Ctrl+V 把思维导图替换成纯文本”。延后一拍把焦点拉回 wrapper,保证快捷键/粘贴作用域稳定。 if (effectiveFullscreen) return; window.setTimeout(() => { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } }, 0); }; document.addEventListener("pointerdown", onPointerDownCapture, true); document.addEventListener("mousedown", onPointerDownCapture, true); return () => { document.removeEventListener("pointerdown", onPointerDownCapture, true); document.removeEventListener("mousedown", onPointerDownCapture, true); }; }, [effectiveFullscreen]); // 关键:阻止鼠标事件冒泡到 BlockNote/ProseMirror(它们会在 contenteditable 上处理 mousedown,从而产生 NodeSelection)。 // 不能用 React 的 onMouseDown(事件委托在 document,太晚了),必须用原生监听挂在 wrapper 上,确保在 bubble 链路中先于 editor DOM。 useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) return; const stopBubble = (e: Event) => { const target = e.target as HTMLElement | null; if (!target) return; // 节点文本编辑态 / 输入框:允许事件继续冒泡,避免影响输入法/选择/粘贴 const tag = target.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return; const mm = mindmapRef.current; const editClasses = mm?.editNodeClassList; if (editClasses) { for (const cls of editClasses) { if (!cls) continue; if (target.classList?.contains(cls)) return; const found = target.closest?.(`.${cls}`); if (found) return; } } // 只要在思维导图块内点击,就阻止冒泡到 editor,避免“Ctrl+V 替换整块” e.stopPropagation(); hotkeyScopeRef.current = true; lastInteractionAtRef.current = Date.now(); // 内嵌视图:同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定。 // 全屏(Portal)视图:不能强制 focus wrapper,否则会打断节点双击编辑输入框的焦点。 if (!effectiveFullscreen) { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } window.setTimeout(() => { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } }, 0); } }; wrapper.addEventListener("mousedown", stopBubble); wrapper.addEventListener("pointerdown", stopBubble); return () => { wrapper.removeEventListener("mousedown", stopBubble); wrapper.removeEventListener("pointerdown", stopBubble); }; }, [effectiveFullscreen]); // 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突 useEffect(() => { if (!mindmap) return; const mark = () => { recentNodeDblclickRef.current = true; window.setTimeout(() => { recentNodeDblclickRef.current = false; }, 0); }; mindmap.on?.("node_dblclick", mark); return () => { mindmap.off?.("node_dblclick", mark); }; }, [mindmap]); // 记录“是否处于节点文本编辑态”,并在全屏时做一次“焦点保护”:避免外部组件/浏览器机制 // 在延迟(例如 1s)后抢走焦点,导致编辑框/光标消失(用户反馈)。 useEffect(() => { if (!mindmap) return; const onShow = () => { textEditOpenRef.current = true; // 刚进入编辑态时允许立即 refocus(不抑制) suppressTextEditRefocusUntilRef.current = 0; // 兜底:部分环境下焦点会在进入编辑态后稍后被抢走,这里做一次 1s 的检查 window.setTimeout(() => { if (!effectiveFullscreen) return; if (!textEditOpenRef.current) return; const wrap = document.querySelector( ".smm-node-edit-wrap, .smm-richtext-node-edit-wrap", ) as HTMLElement | null; if (!wrap) return; const style = window.getComputedStyle(wrap); if (style.display === "none" || style.visibility === "hidden") return; const active = document.activeElement as HTMLElement | null; if (active && wrap.contains(active)) return; const editorEl = (wrap.querySelector(".ql-editor") as HTMLElement | null) ?? wrap; try { editorEl.focus({ preventScroll: true }); } catch { // ignore } }, 1100); }; const onHide = () => { textEditOpenRef.current = false; // 用户反馈:全屏下双击节点进入编辑后,约 1s 后光标/输入框会消失。 // 根因通常是初始化兜底激活(或 render_end 回调)在编辑中途清空 active 列表/重新激活根节点,导致编辑被打断。 // 这里把这类“激活根节点”的动作延后到结束编辑后再执行。 if (pendingInitActivateRootRef.current || pendingRenderEndActivateRootRef.current) { pendingInitActivateRootRef.current = false; pendingRenderEndActivateRootRef.current = false; window.setTimeout(() => { const mm = mindmapRef.current; const renderer = mm?.renderer; const root = renderer?.root ?? renderer?.renderTree?._node ?? null; if (!mm || !renderer || !root) return; try { renderer?.clearActiveNodeList?.(); renderer?.addNodeToActiveList?.(root, true); renderer?.emitNodeActiveEvent?.(root); setActiveNodes([root as MindMapNode]); } catch { // ignore } }, 0); } }; mindmap.on?.("before_show_text_edit", onShow); mindmap.on?.("hide_text_edit", onHide); return () => { mindmap.off?.("before_show_text_edit", onShow); mindmap.off?.("hide_text_edit", onHide); }; }, [mindmap, effectiveFullscreen]); // 全屏时:如果编辑框仍在显示但焦点被外部抢走,则自动把焦点拉回编辑框,保证“有光标”的体验。 // 同时尊重用户的“点击外部结束编辑”:当检测到 pointerdown 发生在编辑框之外时,短暂抑制 refocus, // 让 simple-mind-map 正常触发 hide_text_edit。 useEffect(() => { if (!effectiveFullscreen) return; if (typeof document === "undefined") return; const getEditWrap = () => document.querySelector( ".smm-node-edit-wrap, .smm-richtext-node-edit-wrap", ) as HTMLElement | null; const onPointerDownCapture = (e: Event) => { if (!textEditOpenRef.current) return; const wrap = getEditWrap(); if (!wrap) return; const target = e.target as Node | null; if (target && wrap.contains(target)) return; // 用户点击了编辑框外部:允许结束编辑(暂时不强制拉回焦点) suppressTextEditRefocusUntilRef.current = Date.now() + 800; }; const onFocusInCapture = (e: Event) => { if (!textEditOpenRef.current) return; const now = Date.now(); if (now < suppressTextEditRefocusUntilRef.current) return; const wrap = getEditWrap(); if (!wrap) return; const style = window.getComputedStyle(wrap); if (style.display === "none" || style.visibility === "hidden") return; const target = e.target as Node | null; if (target && wrap.contains(target)) return; // 节流:避免 focusin -> refocus -> focusin 的循环 if (now - lastTextEditRefocusAtRef.current < 120) return; lastTextEditRefocusAtRef.current = now; window.setTimeout(() => { if (!textEditOpenRef.current) return; if (Date.now() < suppressTextEditRefocusUntilRef.current) return; const curWrap = getEditWrap(); if (!curWrap) return; const curStyle = window.getComputedStyle(curWrap); if (curStyle.display === "none" || curStyle.visibility === "hidden") return; const active = document.activeElement as HTMLElement | null; if (active && curWrap.contains(active)) return; const focusTarget = (curWrap.querySelector(".ql-editor") as HTMLElement | null) ?? curWrap; try { focusTarget.focus({ preventScroll: true }); } catch { // ignore } }, 0); }; document.addEventListener("pointerdown", onPointerDownCapture, true); document.addEventListener("mousedown", onPointerDownCapture, true); document.addEventListener("focusin", onFocusInCapture, true); return () => { document.removeEventListener("pointerdown", onPointerDownCapture, true); document.removeEventListener("mousedown", onPointerDownCapture, true); document.removeEventListener("focusin", onFocusInCapture, true); }; }, [effectiveFullscreen]); const exitLocalFullscreen = useCallback(() => { setActiveSidebar(null); if (fullscreen) { onExitFullscreen?.(); return; } if (typeof document === "undefined") { setLocalFullscreen(false); return; } if (document.fullscreenElement) { document .exitFullscreen() .catch(() => { // ignore }) .finally(() => setLocalFullscreen(false)); return; } setLocalFullscreen(false); }, [fullscreen, onExitFullscreen]); const enterLocalFullscreen = useCallback(() => { if (fullscreen) return; setLocalFullscreen(true); setActiveSidebar(null); }, [fullscreen]); // 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效) useEffect(() => { if (!localFullscreen) return; if (typeof document === "undefined") return; const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; // 进入全屏后尽量把焦点放到思维导图容器上,保证快捷键立即生效 queueMicrotask(() => { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } }); const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Escape") return; if (fullscreen) return; // 外部传入的 fullscreen 不在这里处理 e.preventDefault(); e.stopPropagation(); exitLocalFullscreen(); }; window.addEventListener("keydown", onKeyDown, true); return () => { window.removeEventListener("keydown", onKeyDown, true); document.body.style.overflow = prevOverflow; }; }, [localFullscreen, fullscreen, exitLocalFullscreen]); // 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键) useLayoutEffect(() => { const onKeyDownCapture = (e: KeyboardEvent) => { const inScope = hotkeyScopeRef.current || (lastInteractionAtRef.current > 0 && Date.now() - lastInteractionAtRef.current < 60_000); if (!inScope) return; const mm = mindmapRef.current; if (!mm || !mindmapReadyRef.current) return; const target = e.target as HTMLElement | null; const editClasses = mm.editNodeClassList; const wrapper = wrapperRef.current; const isInWrapper = Boolean(wrapper && target && wrapper.contains(target)); const isNodeTextEditing = (() => { if (!target || !isInWrapper) return false; const tag = target.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return true; // 仅在 simple-mind-map 的“节点文本编辑元素”上判定为编辑态;不要用 isContentEditable 泛化判断, // 否则会把库内部的隐藏输入层误判成编辑态,导致 Ctrl+C/Ctrl+V 失效。 if (editClasses) { for (const cls of editClasses) { if (!cls) continue; if (target.classList?.contains(cls)) return true; const found = target.closest?.(`.${cls}`); if (found) return true; } } return false; })(); const isMod = e.ctrlKey || e.metaKey; const key = e.key; const lower = key.toLowerCase(); const stop = () => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); }; const pickTargetNode = (inst: MindMapInstance) => { const renderer = inst.renderer; const active = renderer.activeNodeList ?? []; const last = renderer.lastActiveNodeList ?? []; const root = renderer.root ?? renderer.renderTree?._node ?? null; return active[0] ?? last[0] ?? root ?? null; }; if (key === "Enter" && !e.shiftKey && !e.altKey && !isMod) { stop(); const inst = ensureActiveBefore(mm); if (!inst) return; const node = pickTargetNode(inst); if (!node) return; inst.execCommand?.("SET_NODE_ACTIVE", node, true); inst.execCommand?.("INSERT_NODE", false, [node]); // 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘 // 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保 // 切换全屏/刷新后不会丢失。 markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } return; } if (key === "Tab" && !e.shiftKey && !e.altKey && !isMod) { stop(); const inst = ensureActiveBefore(mm); if (!inst) return; const node = pickTargetNode(inst); if (!node) return; inst.execCommand?.("SET_NODE_ACTIVE", node, true); inst.execCommand?.("INSERT_CHILD_NODE", false, [node]); markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } return; } // 节点文本编辑中:只接管 Enter/Tab;其余快捷键交给默认输入行为 //(例如 Ctrl+C/Ctrl+V 作为文本复制粘贴)。 if (isNodeTextEditing) return; if ((key === "Delete" || key === "Backspace") && !e.shiftKey && !e.altKey && !isMod) { stop(); const inst = ensureActiveBefore(mm); if (!inst) return; inst.execCommand?.("REMOVE_NODE"); markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } return; } if (isMod && lower === "c") { stop(); const inst = ensureActiveBefore(mm); if (!inst) return; const node = pickTargetNode(inst); if (node) { inst.execCommand?.("SET_NODE_ACTIVE", node, true); } inst.renderer.copy?.(); return; } if (isMod && lower === "v") { // 兜底:某些场景下(尤其是嵌入编辑器时)浏览器仍会触发原生 paste 事件。 // 这里标记一次,避免我们在 paste capture 里重复执行粘贴逻辑。 skipNextPasteRef.current = true; window.setTimeout(() => { skipNextPasteRef.current = false; }, 200); stop(); const inst = ensureActiveBefore(mm); if (!inst) return; const renderer = inst.renderer; const copyData = renderer.beingCopyData ?? null; if (copyData) { inst.execCommand?.("PASTE_NODE", copyData); markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } return; } renderer.paste?.(); markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } } }; window.addEventListener("keydown", onKeyDownCapture, true); return () => { window.removeEventListener("keydown", onKeyDownCapture, true); }; }, [markLocalEdited]); // 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。 // 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时, // 强制把粘贴交给 simple-mind-map,并阻止 BlockNote 继续处理。 useLayoutEffect(() => { const onPasteCapture = (e: ClipboardEvent) => { const inScope = hotkeyScopeRef.current || (lastInteractionAtRef.current > 0 && Date.now() - lastInteractionAtRef.current < 60_000); if (!inScope) return; const mm = mindmapRef.current; if (!mm || !mindmapReadyRef.current) return; const wrapper = wrapperRef.current; const active = (typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null); const isTextEditingNow = (() => { if (!wrapper || !active) return false; if (!wrapper.contains(active)) return false; const tag = active.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return true; const editClasses = mm.editNodeClassList; if (editClasses) { for (const cls of editClasses) { if (!cls) continue; if (active.classList?.contains(cls)) return true; const found = active.closest?.(`.${cls}`); if (found) return true; } } return false; })(); // 节点文本编辑态:允许默认粘贴(粘贴到节点文本) if (isTextEditingNow) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); // 如果刚刚已经在 keydown 里处理过 Ctrl+V,这里只负责阻止 BlockNote 的 paste if (skipNextPasteRef.current) { skipNextPasteRef.current = false; return; } const inst = ensureActiveBefore(mm); if (!inst) return; // 优先交给 simple-mind-map 自己的 clipboard 逻辑:支持外部文本/图片/链接以及内部节点复制格式 try { inst.renderer.paste?.(); } catch { // ignore } // 兜底持久化:避免快速切换视图导致“看起来没保存” markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } }; window.addEventListener("paste", onPasteCapture, true); return () => { window.removeEventListener("paste", onPasteCapture, true); }; }, [markLocalEdited]); // 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。 // 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。 useLayoutEffect(() => { const onCopyCapture = (e: ClipboardEvent) => { const inScope = hotkeyScopeRef.current || (lastInteractionAtRef.current > 0 && Date.now() - lastInteractionAtRef.current < 60_000); if (!inScope) return; const mm = mindmapRef.current; if (!mm || !mindmapReadyRef.current) return; const wrapper = wrapperRef.current; const active = (typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null); const isTextEditingNow = (() => { if (!wrapper || !active) return false; if (!wrapper.contains(active)) return false; const tag = active.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return true; const editClasses = mm.editNodeClassList; if (editClasses) { for (const cls of editClasses) { if (!cls) continue; if (active.classList?.contains(cls)) return true; const found = active.closest?.(`.${cls}`); if (found) return true; } } return false; })(); if (isTextEditingNow) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); const inst = ensureActiveBefore(mm); if (!inst) return; const renderer = inst.renderer; const node = renderer.activeNodeList?.[0] ?? renderer.lastActiveNodeList?.[0] ?? renderer.root ?? renderer.renderTree?._node ?? null; if (node) inst.execCommand?.("SET_NODE_ACTIVE", node, true); try { inst.renderer.copy?.(); } catch { // ignore } }; window.addEventListener("copy", onCopyCapture, true); return () => { window.removeEventListener("copy", onCopyCapture, true); }; }, []); // 额外兜底:部分浏览器/编辑器会优先通过 beforeinput(insertFromPaste) 走粘贴路径, // 即便我们拦截了 paste,也可能仍触发“替换掉思维导图块”的输入。 // 这里在 capture 阶段统一阻止 paste 相关的 beforeinput,并把实际粘贴交给 simple-mind-map。 useLayoutEffect(() => { const onBeforeInputCapture = (e: InputEvent) => { const inputType = (e as unknown as { inputType?: string }).inputType || ""; if (!inputType || !inputType.toLowerCase().includes("paste")) return; const inScope = hotkeyScopeRef.current || (lastInteractionAtRef.current > 0 && Date.now() - lastInteractionAtRef.current < 60_000); if (!inScope) return; const mm = mindmapRef.current; if (!mm || !mindmapReadyRef.current) return; // 节点文本编辑态:允许默认粘贴(粘贴到节点文本) const wrapper = wrapperRef.current; const active = (typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null); const isTextEditingNow = (() => { if (!wrapper || !active) return false; if (!wrapper.contains(active)) return false; const tag = active.tagName; if (tag === "INPUT" || tag === "TEXTAREA") return true; const editClasses = mm.editNodeClassList; if (editClasses) { for (const cls of editClasses) { if (!cls) continue; if (active.classList?.contains(cls)) return true; const found = active.closest?.(`.${cls}`); if (found) return true; } } return false; })(); if (isTextEditingNow) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation?.(); const inst = ensureActiveBefore(mm); if (!inst) return; try { inst.renderer.paste?.(); } catch { // ignore } markLocalEdited(); try { const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } }; window.addEventListener("beforeinput", onBeforeInputCapture, true); return () => { window.removeEventListener("beforeinput", onBeforeInputCapture, true); }; }, [markLocalEdited]); const persistData = useCallback( (data: unknown) => { // 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑 // 否则切换视图时会用旧数据,表现为"看起来没保存" let safe = canonicalizeMindmapData(data); // 将 signed URL 转换回 asset:id 格式用于保存 if (signedUrlToAssetIdRef.current.size > 0) { safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current); } initialDataRef.current = safe; syncTitleText(safe); window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); // 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror // 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框 // 被迫退出(表现为"有光标但过一会儿就消失")。 // 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载) // 时再统一把最新数据写回 block。 if (!effectiveFullscreen && !standalone) { documentBridge?.updateBlockData?.(safe); } if (docId) { // 同步到本地文件 + Convex(弱依赖) const requestStartedAt = Date.now(); fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: safe }), }) .then(async (resp) => { if (resp.ok) { try { const payload = (await resp.json().catch(() => null)) as any; syncMindmapRouteMeta(payload?.meta); const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : payload?.meta && typeof payload.meta.updatedAt === "string" ? payload.meta.updatedAt : null; if (updatedAt) { lastLocalSavedAtRef.current = updatedAt; // 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁 lastAppliedRemoteUpdatedAtRef.current = updatedAt; // 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了” if (lastLocalEditAtRef.current <= requestStartedAt) { hasLocalEditsRef.current = false; } } } catch { // ignore } const fileName = `mindmap-${mindmapId}.json`; emitAssetsChanged(docId, { id: mindmapId, document_id: docId, asset_type: "mindmap", file_name: fileName, file_url: `/documents/${docId}/${fileName}`, }); } }) .catch(() => {}); } }, [ autosaveKey, docId, mindmapId, effectiveFullscreen, standalone, documentBridge, syncMindmapRouteMeta, syncTitleText, ], ); useEffect(() => { persistDataRef.current = persistData; }, [persistData]); // “有上限的防抖保存”:连续 data_change 只会合并为一次保存,但不会因为持续变化 // 而无限期推迟(避免测试/用户快速切换全屏时出现“看起来没保存”)。 const persistTimerRef = useRef(null); const pendingPersistRef = useRef(null); const schedulePersist = useCallback( (data: unknown) => { pendingPersistRef.current = data; if (typeof window === "undefined") return; if (persistTimerRef.current != null) return; persistTimerRef.current = window.setTimeout(() => { persistTimerRef.current = null; const pending = pendingPersistRef.current; pendingPersistRef.current = null; if (pending) persistData(pending); }, 800); }, [persistData], ); useEffect(() => { return () => { if (persistTimerRef.current != null && typeof window !== "undefined") { window.clearTimeout(persistTimerRef.current); persistTimerRef.current = null; } const pending = pendingPersistRef.current; pendingPersistRef.current = null; if (pending) { try { persistData(pending); } catch { // ignore } } }; }, [persistData]); const initialSyncDone = useRef(false); useEffect(() => { if (!docId || !mindmap || initialSyncDone.current) return; initialSyncDone.current = true; const data = canonicalizeMindmapData( mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData, ); (async () => { try { const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data, createOnly: true }), }); if (!resp.ok) { } else { const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null; syncMindmapRouteMeta(payload?.meta); } } catch {} finally { // 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新 const fileName = `mindmap-${mindmapId}.json`; emitAssetsChanged(docId, { id: mindmapId, document_id: docId, asset_type: "mindmap", file_name: fileName, file_url: `/documents/${docId}/${fileName}`, }); } })(); }, [docId, mindmap, mindmapId, syncMindmapRouteMeta]); // 初始选中根节点,后续不强制抢焦点,允许用户自由选择 useEffect(() => { if (!mindmap || activeNodes.length > 0) return; const rootNode = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node; if (rootNode) { setActiveNodes([rootNode as MindMapNode]); mindmap.renderer.activeNodeList = [rootNode]; mindmap.renderer.lastActiveNodeList = [rootNode]; mindmap.emit?.("node_active", rootNode, [rootNode]); } }, [mindmap, activeNodes.length]); // mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap-.json useEffect(() => { if (!docId || !mindmap) return; const fileName = `mindmap-${mindmapId}.json`; emitAssetsChanged(docId, { id: mindmapId, document_id: docId, asset_type: "mindmap", file_name: fileName, file_url: `/documents/${docId}/${fileName}`, }); }, [docId, mindmap, mindmapId]); useEffect(() => { let destroyed = false; let createdInstance: MindMapInstance | null = null; (async () => { // 进入/退出全屏会切换渲染树,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 }, { default: AssociativeLine }, { default: OuterFrame }, { default: Exporter }, { default: Formula }, { default: RichText }, { default: MiniMapPlugin }, { default: Select }, { default: Drag }, { default: KeyboardNavigation }, { default: NodeImgAdjust }, { default: Scrollbar }, { default: RainbowLines }, { default: Watermark }, { default: TouchEvent }, { default: Cooperate }, { default: Demonstrate }, { default: MindMapLayoutPro }, { default: NodeBase64ImageStorage }, { default: ExportPDF }, { default: ExportXMind }, 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"), import("simple-mind-map/src/plugins/NodeImgAdjust.js"), import("simple-mind-map/src/plugins/Scrollbar.js"), import("simple-mind-map/src/plugins/RainbowLines.js"), import("simple-mind-map/src/plugins/Watermark.js"), import("simple-mind-map/src/plugins/TouchEvent.js"), import("simple-mind-map/src/plugins/Cooperate.js"), import("simple-mind-map/src/plugins/Demonstrate.js"), import("simple-mind-map/src/plugins/MindMapLayoutPro.js"), import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"), import("simple-mind-map/src/plugins/ExportPDF.js"), import("simple-mind-map/src/plugins/ExportXMind.js"), import("simple-mind-map/src/core/render/node/MindMapNode.js"), ]); // React StrictMode(开发环境)会触发 effect 的“挂载-卸载-再挂载”流程: // 若异步加载完成时已经卸载,不应再创建实例,否则可能留下残余 DOM,导致出现多个 root。 if (destroyed) return; // 兜底修补 simple-mind-map 在创建富文本节点时 host/缓存未就绪导致的空指针 type MindMapNodeCtorLike = { prototype: { __wolaiRichtextPatched?: boolean; createRichTextNode?: (...args: unknown[]) => unknown; }; }; const MindMapNodeCtor = (MindMapNodeModule as unknown as { default?: MindMapNodeCtorLike })?.default; if ( MindMapNodeCtor && !MindMapNodeCtor.prototype.__wolaiRichtextPatched ) { const originalCreate = MindMapNodeCtor.prototype.createRichTextNode; MindMapNodeCtor.prototype.__wolaiRichtextPatched = true; MindMapNodeCtor.prototype.createRichTextNode = function patched(...args: unknown[]) { type RichtextThis = { mindMap?: { el?: HTMLElement | null; commonCaches?: Record } | null; }; const self = this as unknown as RichtextThis; // host 兜底:优先使用实例容器,否则退回 body const host: HTMLElement = (self?.mindMap?.el as HTMLElement | null) ?? document.body; if (!self.mindMap) { self.mindMap = { el: host, commonCaches: {} }; } const el = self.mindMap.el; if (!el || typeof el.appendChild !== "function") { self.mindMap.el = host; } const caches = self.mindMap.commonCaches ?? (self.mindMap.commonCaches = {}); const measureKey = "measureRichtextNodeTextSizeEl"; if (!caches[measureKey]) { const measureDiv = document.createElement("div"); measureDiv.style.position = "fixed"; measureDiv.style.left = "-999999px"; (self.mindMap.el ?? document.body).appendChild(measureDiv); caches[measureKey] = 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 }, { name: "NodeImgAdjust", plugin: NodeImgAdjust }, { name: "Scrollbar", plugin: Scrollbar }, { name: "RainbowLines", plugin: RainbowLines }, { name: "Watermark", plugin: Watermark }, { name: "TouchEvent", plugin: TouchEvent }, { name: "Cooperate", plugin: Cooperate }, { name: "Demonstrate", plugin: Demonstrate }, { name: "MindMapLayoutPro", plugin: MindMapLayoutPro }, { name: "NodeBase64ImageStorage", plugin: NodeBase64ImageStorage }, { name: "ExportPDF", plugin: ExportPDF }, { name: "ExportXMind", plugin: ExportXMind }, ]; plugins.forEach(({ plugin }) => { if (!plugin) { return; } const MindMapCtor = MindMap as unknown as { hasPlugin?: (p: unknown) => number; usePlugin?: (p: unknown) => void; }; const hasPlugin = MindMapCtor.hasPlugin; const notRegistered = typeof hasPlugin === "function" ? hasPlugin(plugin) === -1 : true; const registerPlugin = MindMapCtor.usePlugin; if (notRegistered && typeof registerPlugin === "function") { registerPlugin(plugin); } }); const { nodeIconList, mergerIconList } = await loadIconModules(); await patchSvgRbox(); if (destroyed) return; const hostEl = hostContainer; // 防止同一容器残留旧实例的 svg/dom(StrictMode 或异常 destroy 场景) try { hostEl.replaceChildren(); } catch { hostEl.innerHTML = ""; } // 全屏/非全屏切换会重建实例:创建新实例前优先从本地缓存读取最新数据 // 以避免“全屏里编辑 → 退出全屏后内容消失 / 反之亦然”。 const rawForInit = (() => { if (typeof window === "undefined") { return initialData ?? initialDataRef.current ?? defaultMindmapData; } try { const cached = window.localStorage.getItem(autosaveKey); if (cached) { return JSON.parse(cached); } } catch { // ignore } return initialData ?? initialDataRef.current ?? defaultMindmapData; })(); const dataForInitCanonical = canonicalizeMindmapData(rawForInit); initialDataRef.current = dataForInitCanonical; syncTitleText(dataForInitCanonical); // 将 asset:id 转换为签名 URL,并获取映射 const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical); // 保存映射供后续保存时使用 signedUrlToAssetIdRef.current = urlToAssetId; type MindMapConstructor = new (options: { el: HTMLElement; data: unknown; theme: string; layout: string; mousewheelAction: string; enableFreeDrag: boolean; enableCtrlKeyNodeSelection: boolean; fit: boolean; useLeftKeySelectionRightKeyDrag: boolean; createNewNodeBehavior: string; iconList: unknown[]; customInnerElsAppendTo?: HTMLElement; nodeTextEditZIndex?: number; }) => MindMapInstance; const MindMapCtor = MindMap as unknown as MindMapConstructor; // Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响: // 若 z-index 低于我们的全屏覆盖层,会出现"进入编辑态但看不到独立输入框/光标"的问题。 // 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。 const fullscreenTextEditOptions = effectiveFullscreen ? { // 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。 // 注意:如果把编辑框 append 到 wrapper(overflow-hidden),可能会影响 box-shadow/可见性。 nodeTextEditZIndex: 100000, // 将图片调整遮罩挂载到容器内,确保全屏下可见且事件正常 customInnerElsAppendTo: containerRef.current || undefined, } : null; const instance = new MindMapCtor({ el: hostEl, data: dataForInit, theme: "classic", layout: "logicalStructure", mousewheelAction: "zoom", enableFreeDrag: true, enableCtrlKeyNodeSelection: true, fit: true, useLeftKeySelectionRightKeyDrag: true, // 新建节点默认激活,编辑由我们手动触发,避免初次插入时定位到 (0,0) createNewNodeBehavior: "activeOnly", // 传入扩展图标表,和官方一致 iconList: mergerIconList([ ...nodeIconList, ...(iconConfig as unknown[]), ]), ...(fullscreenTextEditOptions ?? {}), }); createdInstance = instance; mindmapRef.current = instance; // 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用 //(render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。 mindmapReadyRef.current = true; // 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针 type MindMapWithCaches = MindMapInstance & { commonCaches?: Record }; const instanceWithCaches = instance as MindMapWithCaches; if (!instanceWithCaches.commonCaches) { instanceWithCaches.commonCaches = {}; } const measureKey = "measureRichtextNodeTextSizeEl"; if (!instanceWithCaches.commonCaches[measureKey]) { const measureDiv = document.createElement("div"); measureDiv.style.position = "fixed"; measureDiv.style.left = "-999999px"; instanceWithCaches.commonCaches[measureKey] = measureDiv; hostEl.appendChild(measureDiv); } instance.setMode?.("edit"); // 兜底:在某些环境/焦点状态下,simple-mind-map 的 data_change 事件可能不会触发, // 或者键盘插件直接执行命令但未走到我们的保存逻辑,最终表现为“节点能插入但不会持久化”。 // 这里对 execCommand 做轻量包装:当执行潜在的“结构变更”命令时,主动触发一次防抖保存。 const shouldPersistAfterCommand = (cmd: unknown) => { if (typeof cmd !== "string") return false; if ( cmd === "INSERT_NODE" || cmd === "INSERT_CHILD_NODE" || cmd === "PASTE_NODE" || cmd === "REMOVE_NODE" || cmd === "DELETE_NODE" ) { return true; } return ( cmd.startsWith("INSERT_") || cmd.startsWith("PASTE_") || cmd.startsWith("REMOVE_") || cmd.startsWith("DELETE_") ); }; const originalExecCommand = typeof instance.execCommand === "function" ? instance.execCommand.bind(instance) : null; if (originalExecCommand) { (instance as unknown as { execCommand: (...args: any[]) => any }).execCommand = ( cmd: unknown, ...args: any[] ) => { const ret = originalExecCommand(cmd as any, ...args); if ( !applyingRemoteRef.current && !deletingRef.current && shouldPersistAfterCommand(cmd) ) { markLocalEdited(); try { const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; if (snapshot) persistDataRef.current?.(snapshot); } catch { // ignore } } return ret; }; } 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 = renderer?.root ?? renderer?.renderTree?._node; if (rootNode && renderer?.setRootNodeCenter) { renderer.setRootNodeCenter(); } instance.view?.fit?.(); } catch { if (retry < 3) { window.setTimeout(() => centerAndFit(retry + 1), 50); } } }; window.requestAnimationFrame(() => centerAndFit()); window.setTimeout(() => centerAndFit(), 80); if (typeof window !== "undefined") { // 便于开发阶段在控制台直接调试实例 window.__mindmapInstance = instance; const w = window as unknown as { __mindmapInstancesById?: Record; __mindmapPersistById?: Record void>; }; if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {}; w.__mindmapInstancesById[mindmapId] = instance; if (!w.__mindmapPersistById) w.__mindmapPersistById = {}; w.__mindmapPersistById[mindmapId] = (data: unknown) => { persistDataRef.current?.(data); }; } setMindmap(instance); instance.on?.("back_forward", (index: number, len: number) => { setCanBack(index > 0); setCanForward(index < len - 1); }); // 渲染完成后再二次居中,并确保根节点被标记为激活 const handleRenderEnd = () => { const renderer = instance.renderer; const root = getRootNode(instance); if (root) { // 用户正在编辑节点文本时,不要清空 active 列表/切回根节点,避免打断编辑(光标/输入框消失)。 if (textEditOpenRef.current) { pendingRenderEndActivateRootRef.current = true; mindmapReadyRef.current = true; return; } if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList(); if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true); if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root); } mindmapReadyRef.current = true; 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 || []) as MindMapNode[]); // 兜底:部分场景下点击节点不会触发外层 pointerdown(被 stopPropagation), // 这里用内部事件标记“当前在思维导图作用域内”,确保 Ctrl+C/Ctrl+V 不会被 BlockNote 抢走。 hotkeyScopeRef.current = true; lastInteractionAtRef.current = Date.now(); // 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus // 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。 if (!effectiveFullscreen) { window.setTimeout(() => { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } }, 0); } }); instance.on?.("node_click", (node: unknown) => { // 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致) const renderer = instance.renderer; const nodeWithActive = node as unknown as { active?: () => void }; if (typeof nodeWithActive.active === "function") { nodeWithActive.active(); } else { // 兜底:手动维护激活列表 if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList(); if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(node, true); if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(node); } const list = renderer?.activeNodeList ?? []; setActiveNodes( list.length === 0 && node ? [node as MindMapNode] : (list as MindMapNode[]), ); hotkeyScopeRef.current = true; lastInteractionAtRef.current = Date.now(); // 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus // 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。 if (!effectiveFullscreen) { window.setTimeout(() => { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } }, 0); } }); instance.on?.("painter_start", () => setPainterMode(true)); instance.on?.("painter_end", () => setPainterMode(false)); instance.on?.("data_change", () => { if (!applyingRemoteRef.current) { markLocalEdited(); } // data_change 的参数在部分场景下可能不是“可直接序列化的完整数据” //(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。 // 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。 const snapshot = instance.getData?.(true) ?? instance.getData?.() ?? null; if (snapshot) schedulePersist(snapshot); }); const renderer = instance.renderer; const rootNode = getRootNode(instance); if (rootNode) { // 初始化时主动标记根节点为选中,确保后续插入子节点有合法的父节点 if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList(); if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(rootNode, true); if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(rootNode); setActiveNodes([rootNode as MindMapNode]); } // 若首次渲染时 root 仍未就绪,设置兜底延迟激活 window.setTimeout(() => { // 全屏下快速进入节点编辑态时,兜底激活会打断编辑;延后到 hide_text_edit 再处理。 if (textEditOpenRef.current) { pendingInitActivateRootRef.current = true; return; } const root = getRootNode(instance); if (!root) return; if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList(); if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true); if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root); setActiveNodes([root as MindMapNode]); }, 120); if (destroyed) { instance.destroy(); } })(); return () => { destroyed = true; try { // 切换“内嵌/全屏”会导致实例重建:这里尽量在销毁前同步一次数据,避免丢失最后一次编辑 const skipPersist = deletingRef.current || (() => { if (!docId || typeof window === "undefined") return false; try { const w = window as unknown as { __wolaiMindmapDeletingKeys?: Set; }; const key = `${docId}:${mindmapId}`; return Boolean( w.__wolaiMindmapDeletingKeys?.has(docId) || w.__wolaiMindmapDeletingKeys?.has(key), ); } catch { return false; } })(); if (!skipPersist && createdInstance && typeof window !== "undefined") { const data = createdInstance.getData?.(true) ?? createdInstance.getData?.(); if (data) { const safe = canonicalizeMindmapData(data); initialDataRef.current = safe; syncTitleText(safe); try { window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); } catch { // ignore } if (!standalone) { try { documentBridge?.updateBlockData?.(safe); } catch { // ignore } } if (docId) { fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: safe }), }).catch(() => { // ignore }); } } } createdInstance?.destroy(); } catch { // ignore } if (typeof window !== "undefined") { if (window.__mindmapInstance === createdInstance) { window.__mindmapInstance = null; } try { const w = window as unknown as { __mindmapInstancesById?: Record; __mindmapPersistById?: Record void>; }; if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) { delete w.__mindmapInstancesById[mindmapId]; } if (w.__mindmapPersistById && w.__mindmapPersistById[mindmapId]) { delete w.__mindmapPersistById[mindmapId]; } } catch { // ignore } } setMindmap(null); mindmapRef.current = null; mindmapReadyRef.current = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [docId, effectiveFullscreen, initialData, mindmapId, standalone, documentBridge, syncTitleText]); const getInstanceCandidate = () => mindmap ?? (typeof window !== "undefined" ? ((window as unknown as { __mindmapInstance?: MindMapInstance | null }) .__mindmapInstance ?? 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?.lastActiveNodeList; return (list && list[0]) || (last && last[0]) || getRootNode(mm) || null; }; const getNodeUid = (node: unknown): string | null => { const maybeNode = node as unknown as { getData?: (key: string) => unknown; uid?: unknown }; const raw = typeof maybeNode.getData === "function" ? maybeNode.getData("uid") : maybeNode.uid; return typeof raw === "string" && raw ? raw : null; }; const handleUndo = () => runWhenReady((mm) => mm.execCommand("BACK")); const handleRedo = () => runWhenReady((mm) => mm.execCommand("FORWARD")); const handlePainter = () => { const instance = getInstanceCandidate(); if (!instance?.painter) { window.alert("格式刷插件未就绪"); return; } instance.painter.startPainter(); }; const handleSibling = () => execWithReflow((mm) => { const target = pickActiveOrRoot(mm); if (!target) return; const targetUid = getNodeUid(target); 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; if ((renderer.activeNodeList?.length ?? 0) > 0) return; if (!targetUid || typeof renderer.findNodeByUid !== "function") return; const currentTarget = renderer.findNodeByUid(targetUid) as unknown as { parent?: { children?: unknown[] } | null; }; const parent = currentTarget?.parent ?? null; const siblings = (parent?.children ?? []) as unknown[]; const idx = siblings.indexOf(currentTarget as unknown); 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 = getNodeUid(parent); mm.execCommand?.("SET_NODE_ACTIVE", parent, true); // openEdit=false:同上,避免插入瞬间强制进入编辑导致异常 mm.execCommand?.("INSERT_CHILD_NODE", false, [parent]); // 兜底:确保新插入的子节点成为激活节点,保证随后点击“同级节点/删除节点”可用 window.setTimeout(() => { try { const renderer = mm.renderer; if ((renderer.activeNodeList?.length ?? 0) > 0) return; if (!parentUid || typeof renderer.findNodeByUid !== "function") return; const currentParent = renderer.findNodeByUid(parentUid) as unknown as { children?: unknown[]; }; const children = (currentParent?.children ?? []) as unknown[]; 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 = () => { runWhenReady((instance) => { const mm = ensureActiveBefore(instance); if (!mm) return; window.setTimeout(() => mm.execCommand("REMOVE_NODE"), 0); }); }; const handleSummary = () => { runWhenReady((instance) => { const mm = ensureActiveBefore(instance); if (mm) { window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0); } }); }; const handleAssociativeLine = () => { runWhenReady((instance) => { const mm = ensureActiveBefore(instance); if (mm) { window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0); } }); }; const handleOuterFrame = () => { runWhenReady((instance) => { const mm = ensureActiveBefore(instance); 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 [uploadingImage, setUploadingImage] = useState(false); const [workspaceId, setWorkspaceId] = useState(() => readInitialWorkspaceId(initialProjection?.meta)); const fileInputForImage = useRef(null); // 图片预览(双击节点图片) const [showImageViewer, setShowImageViewer] = useState(false); const [viewerSrc, setViewerSrc] = useState(""); const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({}); const [viewerZoom, setViewerZoom] = useState(1); // 解析图片 URL,如果是 asset_id 格式则获取签名 URL const resolveImageUrl = async (urlOrAssetId: string): Promise => { // 检查是否是 asset_id 格式 if (urlOrAssetId.startsWith("asset:")) { const assetId = urlOrAssetId.replace("asset:", ""); try { const response = await fetch(`/api/media/sign?assetId=${assetId}`); if (response.ok) { const data = await response.json(); return data.signedUrl; } } catch {} return urlOrAssetId; } // 如果是完整的 URL,直接返回 return urlOrAssetId; }; // 递归收集思维导图中所有的图片 URL const collectImageUrls = (data: any): string[] => { if (!data) return []; const urls: string[] = []; const traverse = (node: any) => { if (!node) return; const candidates: unknown[] = [ node?.data?.image, node?.image, node?.image?.url, node?.data?.image?.url, ]; candidates.forEach((value) => { if (typeof value === "string" && value) { urls.push(value); } }); if (Array.isArray(node.children)) { node.children.forEach(traverse); } }; traverse(data); return urls; }; // 处理图片删除(将 asset 移到垃圾桶) const deleteImageAssets = useCallback( async (assetIds: string[]) => { if (!assetIds.length || !docId) return; try { const response = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "delete", assetIds }), }); if (response.ok) { // 记录到已删除列表 assetIds.forEach((id) => deletedAssetIdsRef.current.add(id)); // 通知文件树刷新,传递被删除的 assetIds emitAssetsChanged(docId, undefined, assetIds); } else { const payload = await response.json().catch(() => null); console.error("删除图片资源失败", payload?.error); } } catch (e) { console.error("删除图片资源失败", e); } }, [docId], ); // 处理图片恢复(从垃圾桶恢复) const restoreImageAssets = useCallback( async (assetIds: string[]) => { if (!assetIds.length || !docId) return; try { const response = await fetch("/api/media/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "restore", assetIds }), }); if (response.ok) { // 从已删除列表移除 assetIds.forEach((id) => deletedAssetIdsRef.current.delete(id)); // 通知文件树刷新,传递恢复的 assetIds(空列表表示需要重新获取) emitAssetsChanged(docId); } } catch (e) { console.error("恢复图片资源失败", e); } }, [docId], ); 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 imgNodeRef = useRef<{ node: MindMapNode | null; imgNode: any }>({ node: null, imgNode: null }); const handleImage = () => { const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined; if (first?.getStyle) { const placement = first.getStyle("imgPlacement", false) as string; if (placement) setImagePosition(placement); } setShowImageModal(true); }; // 图片工具栏更新位置函数 const updateImgToolbarPos = useCallback(() => { if (!imgToolbarState.show || !imgNodeRef.current.imgNode || !imgToolbarRef.current) return; const toolbarRect = imgToolbarRef.current.getBoundingClientRect(); const imgRbox = imgNodeRef.current.imgNode?.rbox?.(); if (!imgRbox) return; const { width: imgWidth, x, y } = imgRbox; setImgToolbarState((s) => ({ ...s, x: x + imgWidth / 2 - toolbarRect.width / 2, y: y - toolbarRect.height - 5, })); }, [imgToolbarState.show]); // 预览:监听 mindmap 事件 useEffect(() => { if (!mindmap) return; const handler = async (node: MindMapNode, e?: Event) => { e?.stopPropagation?.(); e?.preventDefault?.(); const srcRaw = node?.nodeData?.data?.image || node?.getData?.("image") || node?.data?.image; const src = typeof srcRaw === "string" ? srcRaw : ""; if (src) { // 解析图片 URL,如果是 asset_id 格式则获取签名 URL const resolvedUrl = await resolveImageUrl(src); setViewerSrc(resolvedUrl); const sizeRaw = node?.getData?.("imageSize"); const size = sizeRaw && typeof sizeRaw === "object" ? (sizeRaw as { width?: unknown; height?: unknown }) : ({} as { width?: unknown; height?: unknown }); const title = node?.getData?.("imageTitle") || ""; setViewerMeta({ title: typeof title === "string" ? title : "", width: Number(size.width) || undefined, height: Number(size.height) || undefined, }); setViewerZoom(1); // 重置缩放 setShowImageViewer(true); } }; const onActive = (node: MindMapNode) => { if (node === imgNodeRef.current.node) return; const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[]; const has = list.some((n) => !!n?.getData?.("image")); if (!has) { setImgToolbarState((s) => ({ ...s, show: false })); imgNodeRef.current = { node: null, imgNode: null }; } }; const showToolbarOnClick = (node: MindMapNode, imgNode: any) => { imgNodeRef.current = { node, imgNode }; const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right"; setImagePosition(placement); setImgToolbarState((s) => ({ ...s, show: true, placement, })); // 使用 requestAnimationFrame 确保 DOM 更新后再定位 requestAnimationFrame(() => { updateImgToolbarPos(); }); }; const hideToolbar = () => { if (!imgToolbarHover.current) { setImgToolbarState((s) => ({ ...s, show: false })); imgNodeRef.current = { node: null, imgNode: null }; } }; mindmap.on?.("node_img_dblclick", handler); mindmap.on?.("node_active", onActive); mindmap.on?.("node_img_click", showToolbarOnClick); mindmap.on?.("draw_click", hideToolbar); // 添加更多事件监听以正确隐藏/更新工具栏 mindmap.on?.("svg_mousedown", hideToolbar); mindmap.on?.("node_dblclick", hideToolbar); mindmap.on?.("scale", updateImgToolbarPos); mindmap.on?.("translate", hideToolbar); mindmap.on?.("node_img_adjust_btn_mousedown", hideToolbar); mindmap.on?.("delete_node_img_from_delete_btn", 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.off?.("svg_mousedown", hideToolbar); mindmap.off?.("node_dblclick", hideToolbar); mindmap.off?.("scale", updateImgToolbarPos); mindmap.off?.("translate", hideToolbar); mindmap.off?.("node_img_adjust_btn_mousedown", hideToolbar); mindmap.off?.("delete_node_img_from_delete_btn", hideToolbar); }; }, [mindmap, updateImgToolbarPos]); // 处理图片删除/恢复(支持撤销) useEffect(() => { if (!mindmap || !docId) return; // 初始化:收集当前所有图片 URL const initialData = mindmap.getData?.(); if (initialData) { const urls = collectImageUrls(initialData); currentImageUrlsRef.current = new Set(urls); } // 处理数据变化 const handleDataChange = () => { const newData = mindmap.getData?.(); if (!newData) return; const newUrls = collectImageUrls(newData); const newUrlsSet = new Set(newUrls); const oldUrlsSet = currentImageUrlsRef.current; // 检测被删除的图片(在旧集合中但不在新集合中) const deletedUrls: string[] = []; oldUrlsSet.forEach(url => { if (!newUrlsSet.has(url)) { deletedUrls.push(url); } }); // 检测新增的图片(在新集合中但不在旧集合中) const addedUrls: string[] = []; newUrlsSet.forEach(url => { if (!oldUrlsSet.has(url)) { addedUrls.push(url); } }); // 处理删除的图片 if (deletedUrls.length > 0) { const assetIdsToDelete: string[] = []; deletedUrls.forEach(url => { const assetId = signedUrlToAssetIdRef.current.get(url); if (assetId) { assetIdsToDelete.push(assetId); } }); if (assetIdsToDelete.length > 0) { deleteImageAssets(assetIdsToDelete); } } // 处理恢复的图片(可能是撤销操作) if (addedUrls.length > 0) { const assetIdsToRestore: string[] = []; addedUrls.forEach(url => { const assetId = signedUrlToAssetIdRef.current.get(url); if (assetId && deletedAssetIdsRef.current.has(assetId)) { assetIdsToRestore.push(assetId); } }); if (assetIdsToRestore.length > 0) { restoreImageAssets(assetIdsToRestore); } } // 更新当前图片集合 currentImageUrlsRef.current = newUrlsSet; }; // 监听多种事件 mindmap.on?.("data_change", handleDataChange); mindmap.on?.("back_forward", handleDataChange); mindmap.on?.("node_data_change", handleDataChange); return () => { mindmap.off?.("data_change", handleDataChange); mindmap.off?.("back_forward", handleDataChange); mindmap.off?.("node_data_change", handleDataChange); }; }, [deleteImageAssets, docId, mindmap, restoreImageAssets]); // 悬浮图片位置工具条(参考 NodeImgPlacementToolbar.vue) const renderImgToolbar = () => { // 预览打开时隐藏工具栏 if (!imgToolbarState.show || showImageViewer) return null; const { x, y, placement } = imgToolbarState; const setPlacement = (p: "top" | "bottom" | "left" | "right") => { setImagePosition(p); applyToActiveNodes(mindmap, (n) => mindmap?.execCommand?.("SET_NODE_STYLES", n, { imgPlacement: p }), ); setImgToolbarState((s) => ({ ...s, placement: p })); }; // 位置按钮组件 const PosBtn = ( p: typeof placement, Icon: React.ComponentType<{ className?: string }>, title: string, ) => ( ); return (
e.stopPropagation()} > {PosBtn("top", ArrowUp, "顶部")} {PosBtn("bottom", ArrowDown, "底部")} {PosBtn("left", ArrowLeft, "靠左")} {PosBtn("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 = async () => { const url = imageUrl.trim(); // 获取原始图片尺寸,如果没有则使用默认值 const width = Number(imageWidth) || 0; const height = Number(imageHeight) || 0; if (!url) { window.alert("请输入图片链接"); return; } setShowImageModal(false); // 如果是 asset:id 格式,需要先转换为 signed URL 供显示 let displayUrl = url; if (url.startsWith("asset:")) { displayUrl = await resolveImageUrl(url); // 记录映射关系供保存/删除/撤销使用(无论是否签名成功) const assetId = url.replace(/^asset:/, "").trim(); if (assetId) { signedUrlToAssetIdRef.current.set(displayUrl, assetId); signedUrlToAssetIdRef.current.set(`asset:${assetId}`, assetId); } } applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_IMAGE", node, { url: displayUrl, width, height, title: imageTitle || "", custom: false, // 让 simple-mind-map 自动缩放到合适尺寸 }), ); applyToActiveNodes(mindmap, (node) => mindmap?.execCommand?.("SET_NODE_STYLES", node, { imgPlacement: imagePosition || "top", }), ); // 等待命令执行后更新 currentImageUrlsRef setTimeout(() => { const newData = mindmap?.getData?.(); if (newData) { const newUrls = collectImageUrls(newData); currentImageUrlsRef.current = new Set(newUrls); } }, 100); }; const handleAttachment = () => { const url = createSimplePrompt("附件链接(http/https)"); if (!url) return; const name = createSimplePrompt("附件名称(可选)", "附件"); applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_ATTACHMENT", node, url, name ?? "")); }; const handleAiPlaceholder = () => { window.alert("AI 能力占位:后续接入大模型生成/优化节点内容。"); }; const handleImport = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; const reset = () => { event.target.value = ""; }; const ext = (file.name.split(".").pop() || "").toLowerCase(); try { // JSON / smm if (ext === "json" || ext === "smm") { const text = await file.text(); const data = JSON.parse(text); mindmap?.setData(data); mindmap?.command.clearHistory(); persistData(data); return; } // XMind if (ext === "xmind") { const xmindParser = await import("simple-mind-map/src/parse/xmind.js"); const blob = new Blob([await file.arrayBuffer()]); const data = await xmindParser.default.parseXmindFile(blob, (content) => { const list = content; if (list.length > 1) { window.alert("检测到 XMind 多画布,自动导入第一个画布。"); } return list.length > 0 ? list[0] : content; }); mindmap?.setData(data); mindmap?.command.clearHistory(); persistData(data); return; } // MindManager (.mmap) if (ext === "mmap") { const { parseMindManagerMmapFile } = await import("./mindmapMindManagerImport"); const data = await parseMindManagerMmapFile(file); mindmap?.setData(data); mindmap?.command.clearHistory(); persistData(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(); persistData(data); return; } window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md"); } catch { window.alert("导入失败:文件格式或内容错误"); } finally { reset(); } }; const handleNew = () => { if (!window.confirm("确定要新建空白导图吗?当前未保存的修改将被覆盖。")) return; mindmap?.setData(defaultMindmapData); mindmap?.command.clearHistory(); persistData(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) ?? initialDataRef.current ?? initialData ?? defaultMindmapData; downloadJson(data, "mindmap"); }; const handleExport = async (type: string, name = "mindmap") => { try { await mindmap?.doExport?.export(type, true, name); } catch { window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`); } }; const handleExportPng = () => handleExport("png"); const handleExportSvg = () => handleExport("svg"); const handleExportPdf = () => handleExport("pdf"); const handleExportMd = () => handleExport("md"); const handleExportTxt = () => handleExport("txt"); const handleExportXmind = () => handleExport("xmind"); const handleSaveAs = () => handleExportJson(); const handleDeleteMindmap = useCallback(async () => { if (!docId) { deletingRef.current = true; documentBridge?.removeBlock?.(); return; } const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。"); if (!confirmed) return; deletingRef.current = true; const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" }); if (!resp.ok) { const payload = await resp.json().catch(() => ({})); window.alert(payload?.error ?? "删除思维导图失败"); deletingRef.current = false; return; } try { window.localStorage.removeItem(autosaveKey); } catch { // ignore } emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]); // 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错 try { documentBridge?.removeBlock?.(); } catch { // ignore } }, [autosaveKey, docId, mindmapId, documentBridge]); const toolbarProps = { canBack, canForward, painterMode, onUndo: handleUndo, onRedo: handleRedo, onPainter: handlePainter, onSibling: handleSibling, onChild: handleChild, onDelete: handleDelete, onImage: handleImage, onIcon: handleIcon, onLink: handleLink, onNote: handleNote, onTag: handleTag, onSummary: handleSummary, onAssociativeLine: handleAssociativeLine, onFormula: handleFormula, onAttachment: handleAttachment, onOuterFrame: handleOuterFrame, onAi: handleAiPlaceholder, onImport: handleImport, onNew: handleNew, onOpenDirectory: handleOpenDirectory, onSaveAs: handleSaveAs, onDeleteMindmap: handleDeleteMindmap, onExportJson: handleExportJson, onExportPng: handleExportPng, onExportSvg: handleExportSvg, onExportPdf: handleExportPdf, onExportMd: handleExportMd, onExportTxt: handleExportTxt, onExportXmind: handleExportXmind, fileInputRef, }; const noteModal = showNoteModal ? (
备注