Files
mnote/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx
T
2026-01-11 12:35:53 +08:00

3202 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
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";
// 避免 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<string, unknown> =>
typeof v === "object" && v !== null && !Array.isArray(v);
type MindMapInstance = {
execCommand: (command: string, ...args: unknown[]) => void;
destroy: () => void;
setData: (data: unknown) => void;
getData: (withConfig?: boolean) => unknown;
on?: <TArgs extends unknown[]>(event: string, handler: (...args: TArgs) => void) => void;
emit?: (event: string, ...args: unknown[]) => void;
off?: <TArgs extends unknown[]>(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<unknown> };
width: number;
height: number;
miniMap?: unknown;
getThemeConfig: (key: string) => unknown;
setThemeConfig: (config: unknown) => void;
setTheme: (theme: string) => void;
setLayout: (layout: string) => void;
};
type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
export const defaultMindmapData = {
data: { text: "中心主题" },
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;
};
// 将思维导图数据中的 asset:id 格式转换为实际的签名 URL
// 返回转换后的数据和 signed URL -> asset ID 的映射
const resolveAssetUrls = async (data: MindMapData): Promise<{ data: MindMapData; urlToAssetId: Map<string, string> }> => {
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<string, string>();
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<string, string>();
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<string, string>): 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 MindmapBlockView = ({
block,
editor,
fullscreen = false,
onExitFullscreen,
}: {
block: SpecificBlock<
CustomBlockSchema,
"mindmap",
DefaultInlineContentSchema,
DefaultStyleSchema
>;
editor: BlockNoteEditor<CustomBlockSchema>;
fullscreen?: boolean;
onExitFullscreen?: () => void;
}) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
const mindmapReadyRef = useRef(false);
const mindmapRef = useRef<MindMapInstance | null>(null);
const hasLocalEditsRef = useRef(false);
const applyingRemoteRef = useRef(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<MindMapNode[]>([]);
const [painterMode, setPainterMode] = useState(false);
const [showMiniMap, setShowMiniMap] = useState(false);
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
const [showNoteModal, setShowNoteModal] = useState(false);
const [noteContent, setNoteContent] = useState("");
const [localFullscreen, setLocalFullscreen] = useState(false);
const effectiveFullscreen = fullscreen || localFullscreen;
const wrapperRef = useRef<HTMLDivElement | null>(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<string, string>());
// 存储当前思维导图中使用的所有图片 URL,用于检测图片删除
const currentImageUrlsRef = useRef(new Set<string>());
// 存储已删除的 asset ID,用于撤销时恢复
const deletedAssetIdsRef = useRef(new Set<string>());
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
const instance = mm ?? mindmap;
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]);
const docId = useMemo(
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const mindmapId = block.id;
// 获取 workspaceId(用于上传图片)
useEffect(() => {
let cancelled = false;
if (!docId) return;
void (async () => {
try {
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
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;
applyingRemoteRef.current = false;
}, [docId]);
const autosaveKey = useMemo(() => {
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
return `${STORAGE_PREFIX}${mindmapId}`;
}, [docId, mindmapId]);
const initialDataRef = useRef<unknown>(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(block.props.data ?? defaultMindmapData);
}
} else {
initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData);
}
}
// 优先加载本地文件,其次 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;
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return;
initialDataRef.current = canonicalizeMindmapData(data);
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]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
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) 而不是 microtaskBlockNote/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;
const editorEl =
(wrap.querySelector(".ql-editor") as HTMLElement | null) ?? wrap;
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 插件不走),这里主动做一次防抖保存,确保
// 切换全屏/刷新后不会丢失。
hasLocalEditsRef.current = true;
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]);
hasLocalEditsRef.current = true;
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");
hasLocalEditsRef.current = true;
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);
hasLocalEditsRef.current = true;
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
} catch {
// ignore
}
return;
}
renderer.paste?.();
hasLocalEditsRef.current = true;
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);
};
}, []);
// 兜底:在非全屏嵌入 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
}
// 兜底持久化:避免快速切换视图导致“看起来没保存”
hasLocalEditsRef.current = true;
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);
};
}, []);
// 兜底: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
}
hasLocalEditsRef.current = true;
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);
};
}, []);
const persistData = useCallback(
(data: unknown) => {
if (!editor) return;
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
// 否则切换视图时会用旧数据,表现为"看起来没保存"
let safe = canonicalizeMindmapData(data);
// 将 signed URL 转换回 asset:id 格式用于保存
if (signedUrlToAssetIdRef.current.size > 0) {
safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current);
}
initialDataRef.current = safe;
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlockBlockNote/ProseMirror
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
// 被迫退出(表现为"有光标但过一会儿就消失")。
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
// 时再统一把最新数据写回 block。
if (!effectiveFullscreen) {
editor.updateBlock(block, { props: { ...block.props, data: safe } });
}
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: safe }),
})
.then((resp) => {
if (resp.ok) {
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, block, docId, editor, mindmapId, effectiveFullscreen],
);
useEffect(() => {
persistDataRef.current = persistData;
}, [persistData]);
// “有上限的防抖保存”:连续 data_change 只会合并为一次保存,但不会因为持续变化
// 而无限期推迟(避免测试/用户快速切换全屏时出现“看起来没保存”)。
const persistTimerRef = useRef<number | null>(null);
const pendingPersistRef = useRef<unknown | null>(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) {
}
} 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, initialDataRef]);
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
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-<id>.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<HTMLDivElement | null> => {
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<string, unknown> } | 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(({ name, 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/domStrictMode 或异常 destroy 场景)
try {
hostEl.replaceChildren();
} catch {
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 dataForInitCanonical = canonicalizeMindmapData(rawForInit);
initialDataRef.current = 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 到 wrapperoverflow-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<string, unknown> };
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)
) {
hasLocalEditsRef.current = true;
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<string, MindMapInstance>;
__mindmapPersistById?: Record<string, (data: unknown) => 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) {
hasLocalEditsRef.current = true;
}
// 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<string>;
};
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;
try {
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
} catch {
// ignore
}
try {
editor?.updateBlock(block, { props: { ...block.props, data: 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<string, MindMapInstance>;
__mindmapPersistById?: Record<string, (data: unknown) => 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
}, [block.id, effectiveFullscreen]);
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<number | string>(260);
const [imageHeight, setImageHeight] = useState<number | string>(200);
const [imageTitle, setImageTitle] = useState("");
const [imagePosition, setImagePosition] = useState<string>("top");
const [uploadingImage, setUploadingImage] = useState(false);
const [workspaceId, setWorkspaceId] = useState("");
const fileInputForImage = useRef<HTMLInputElement | null>(null);
// 图片预览(双击节点图片)
const [showImageViewer, setShowImageViewer] = useState(false);
const [viewerSrc, setViewerSrc] = useState("");
const [viewerMeta, setViewerMeta] = useState<{ title?: string; width?: number; height?: number }>({});
const [viewerZoom, setViewerZoom] = useState(1);
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
const resolveImageUrl = async (urlOrAssetId: string): Promise<string> => {
// 检查是否是 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 = 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);
}
};
// 处理图片恢复(从垃圾桶恢复)
const restoreImageAssets = 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);
}
};
const imgToolbarRef = useRef<HTMLDivElement | null>(null);
const [imgToolbarState, setImgToolbarState] = useState({
show: false,
x: 0,
y: 0,
placement: "top" as "top" | "bottom" | "left" | "right",
});
const imgToolbarHover = useRef(false);
const 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, _evt: Event | undefined) => {
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);
};
}, [mindmap, docId]);
// 悬浮图片位置工具条(参考 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,
) => (
<button
key={p}
type="button"
className={`flex h-8 w-8 items-center justify-center rounded border bg-white text-gray-700 shadow-sm ${
placement === p ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200 hover:bg-gray-100"
}`}
onClick={(e) => {
e.stopPropagation();
setPlacement(p);
}}
title={title}
>
<Icon className="h-4 w-4" />
</button>
);
return (
<div
ref={imgToolbarRef}
className="pointer-events-auto fixed z-[9999] flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 shadow-md"
style={{ left: x, top: y }}
onClick={(e) => e.stopPropagation()}
>
{PosBtn("top", ArrowUp, "顶部")}
{PosBtn("bottom", ArrowDown, "底部")}
{PosBtn("left", ArrowLeft, "靠左")}
{PosBtn("right", ArrowRight, "靠右")}
</div>
);
};
const handleIcon = () => {
setActiveSidebar("icons");
};
const handleLink = () => {
const href = createSimplePrompt("请输入超链接");
if (!href) return;
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_HYPERLINK", node, href, href));
};
const handleNote = () => {
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();
// 获取原始图片尺寸,如果没有则使用默认值
let width = Number(imageWidth) || 0;
let height = Number(imageHeight) || 0;
if (!url) {
window.alert("请输入图片链接");
return;
}
setShowImageModal(false);
// 保存旧的 signed URL(如果存在),用于后续删除检测
const oldData = mindmap?.getData?.();
const oldUrls = oldData ? collectImageUrls(oldData) : [];
// 如果是 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<HTMLInputElement>) => {
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) ?? block.props.data ?? 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;
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
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 {
editor.removeBlocks([block.id]);
} catch {
// ignore
}
}, [autosaveKey, block.id, docId, editor, mindmapId]);
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 ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-4xl rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-lg font-semibold text-gray-800">备注</span>
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowNoteModal(false)}>
</button>
</div>
<div className="px-4 py-3">
<textarea
className="h-64 w-full resize-none rounded-md border border-gray-200 p-3 text-sm focus:border-blue-400 focus:outline-none"
placeholder="支持富文本/Markdown,内容将写入节点备注"
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
/>
</div>
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
<button
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setShowNoteModal(false)}
>
取消
</button>
<button
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
onClick={handleNoteConfirm}
>
确定
</button>
</div>
</div>
</div>
) : null;
const imageViewer = showImageViewer ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
<div
className="relative max-h-full max-w-5xl text-white"
onClick={(e) => e.stopPropagation()}
onWheel={(e) => {
e.preventDefault();
e.stopPropagation();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setViewerZoom((prev) => Math.max(0.1, Math.min(5, prev + delta)));
}}
>
<button
className="absolute -top-3 -right-3 rounded-full bg-white/90 px-2 py-1 text-sm text-gray-700 shadow"
onClick={(e) => {
e.stopPropagation();
setShowImageViewer(false);
}}
>
关闭
</button>
<div className="mb-2 flex items-center justify-between gap-4 px-1 text-xs text-gray-200">
<span className="truncate">{viewerMeta.title || "图片预览"}</span>
<div className="flex items-center gap-3">
<span className="shrink-0">{viewerMeta.width && viewerMeta.height ? `${viewerMeta.width} × ${viewerMeta.height}px` : ""}</span>
<span className="shrink-0 text-blue-300">{Math.round(viewerZoom * 100)}%</span>
<button
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
onClick={() => setViewerZoom((prev) => Math.max(0.1, prev - 0.2))}
>
-
</button>
<button
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
onClick={() => setViewerZoom(1)}
>
重置
</button>
<button
className="shrink-0 rounded bg-white/20 px-2 py-0.5 hover:bg-white/30"
onClick={() => setViewerZoom((prev) => Math.min(5, prev + 0.2))}
>
+
</button>
</div>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={viewerSrc}
alt={viewerMeta.title || "预览"}
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white transition-transform duration-100"
style={{ transform: `scale(${viewerZoom})` }}
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
) : null;
// 使用 createPortal 将工具栏挂载到 body,确保 position: fixed 相对于视口
const imgToolbar = typeof document !== "undefined" && imgToolbarState.show
? createPortal(renderImgToolbar(), document.body)
: null;
const imageModal = showImageModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-3xl rounded-xl bg-white shadow-2xl">
<div className="flex items-center justify-between border-b px-4 py-3">
<span className="text-lg font-semibold text-gray-800">插入图片</span>
<button className="text-gray-400 hover:text-gray-600" onClick={() => setShowImageModal(false)}>
</button>
</div>
<div className="space-y-4 px-4 py-3">
<div className="space-y-2">
<Label className="text-xs font-semibold text-gray-600">方式一:上传图片</Label>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
onClick={() => fileInputForImage.current?.click()}
>
选择文件
</button>
<span className="text-xs text-gray-400 truncate">
{uploadingImage
? "上传中..."
: imageUrl.startsWith("asset:")
? "已选择图片"
: imageUrl.startsWith("data:")
? "已选择本地图片"
: imageUrl
? "已输入图片地址"
: "未选择文件"}
</span>
<input
ref={fileInputForImage}
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!workspaceId || !docId) {
window.alert("缺少空间信息,无法上传文件");
return;
}
setUploadingImage(true);
try {
// 先获取原始图片尺寸
const localSize = await getImageSizeSafe(URL.createObjectURL(file));
if (localSize) {
setImageWidth(localSize.width);
setImageHeight(localSize.height);
}
const form = new FormData();
form.append("file", file);
form.append("workspaceId", workspaceId);
form.append("documentId", docId);
if (mindmapId) {
form.append("mindmapId", mindmapId);
}
const response = await fetch("/api/media/upload", {
method: "POST",
body: form,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.error ?? "上传失败");
}
const payload = (await response.json()) as { mindmapUrl?: string };
if (!payload.mindmapUrl) {
throw new Error("返回数据缺少图片地址");
}
setImageUrl(payload.mindmapUrl);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setUploadingImage(false);
if (fileInputForImage.current) fileInputForImage.current.value = "";
}
}}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold text-gray-600">方式二:图片地址</Label>
<Input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/image.png"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold text-gray-600">快捷位置</Label>
<div className="flex gap-2">
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "top" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("top")}
title="顶部"
>
<ArrowUp className="mx-auto h-4 w-4" />
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "bottom" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("bottom")}
title="底部"
>
<ArrowDown className="mx-auto h-4 w-4" />
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "left" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("left")}
title="靠左"
>
<ArrowLeft className="mx-auto h-4 w-4" />
</button>
<button
type="button"
className={`flex-1 rounded border px-2 py-2 text-sm ${imagePosition === "right" ? "border-blue-500 bg-blue-50 text-blue-600" : "border-gray-200"}`}
onClick={() => setImagePosition("right")}
title="靠右"
>
<ArrowRight className="mx-auto h-4 w-4" />
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs text-gray-500">宽度(px</Label>
<Input
type="number"
value={imageWidth}
onChange={(e) => setImageWidth(e.target.value)}
min={10}
placeholder="260"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">高度(px</Label>
<Input
type="number"
value={imageHeight}
onChange={(e) => setImageHeight(e.target.value)}
min={10}
placeholder="200"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">标题(可选)</Label>
<Input
value={imageTitle}
onChange={(e) => setImageTitle(e.target.value)}
placeholder="图片标题"
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-gray-500">位置</Label>
<select
className="h-9 w-full rounded-md border border-gray-200 px-3 text-sm"
value={imagePosition}
onChange={(e) => setImagePosition(e.target.value)}
>
<option value="center">居中</option>
<option value="top">顶部</option>
<option value="bottom">底部</option>
<option value="left">靠左</option>
<option value="right">靠右</option>
</select>
</div>
<p className="text-xs text-gray-400">可选择本地图片或粘贴 URL,默认尺寸 260×200</p>
</div>
<div className="flex items-center justify-end gap-3 border-t px-4 py-3">
<button
className="rounded-md border border-gray-200 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setShowImageModal(false)}
>
取消
</button>
<button
className="rounded-md bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
onClick={handleImageConfirm}
>
插入
</button>
</div>
</div>
</div>
) : null;
if (effectiveFullscreen) {
const fullscreenView = (
<div
ref={wrapperRef}
data-testid="mindmap-fullscreen"
tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
>
<div className="fixed left-1/2 top-2 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full pt-0">
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-mindmap-id={mindmapId}
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
onSelect={setActiveSidebar}
/>
<MindmapSidebar
documentId={docId}
mindmapId={mindmapId}
mindmap={mindmap}
activeNodes={activeNodes}
activeTab={activeSidebar}
onClose={() => setActiveSidebar(null)}
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={effectiveFullscreen}
toggleFullscreen={
fullscreen
? undefined
: () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
<MindmapCount mindmap={mindmap} />
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
<MindmapContextMenu mindmap={mindmap} />
{imgToolbar}
{noteModal}
{imageModal}
{imageViewer}
</div>
</div>
);
if (typeof document === "undefined") return null;
return createPortal(fullscreenView, document.body);
}
return (
<div
ref={wrapperRef}
data-testid="mindmap-embed"
tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
>
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
<div className="w-full">
<MindmapToolbar {...toolbarProps} />
</div>
</div>
<div
data-testid="mindmap-stage"
className="relative h-[520px] w-full overflow-hidden bg-white"
onPointerDown={(e) => {
// 阻止事件冒泡到 BlockNote/ProseMirror,避免产生 NodeSelection 导致粘贴替换整块
e.stopPropagation();
hotkeyScopeRef.current = true;
lastInteractionAtRef.current = Date.now();
// 点击后把焦点落到容器上,避免快捷键被编辑器抢走
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}}
onMouseDown={(e) => {
e.stopPropagation();
hotkeyScopeRef.current = true;
lastInteractionAtRef.current = Date.now();
// 兼容:某些环境下 pointer 事件不触发
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}}
onDoubleClick={() => {
if (recentNodeDblclickRef.current) return;
enterLocalFullscreen();
}}
>
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-mindmap-id={mindmapId}
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
onSelect={setActiveSidebar}
/>
<MindmapSidebar
documentId={docId}
mindmapId={mindmapId}
mindmap={mindmap}
activeNodes={activeNodes}
activeTab={activeSidebar}
onClose={() => setActiveSidebar(null)}
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={effectiveFullscreen}
toggleFullscreen={
fullscreen
? undefined
: () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
<MindmapCount mindmap={mindmap} />
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
<MindmapContextMenu mindmap={mindmap} />
{imgToolbar}
{noteModal}
{imageModal}
{imageViewer}
</div>
</div>
);
};
export { MindmapBlockView };
type MindmapBlockViewProps = Parameters<typeof MindmapBlockView>[0];
export const mindmapBlock = createReactBlockSpec(
{
type: "mindmap",
propSchema: {
docId: { default: "" },
data: { default: defaultMindmapData },
},
content: "none",
} as unknown as BlockConfig<"mindmap", PropSchema, "none">,
{
render: (props) => (
<MindmapBlockView {...(props as unknown as MindmapBlockViewProps)} />
),
},
);