2025-12-28 10:01:30 +08:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
|
|
import "simple-mind-map/dist/simpleMindMap.esm.css";
|
2026-01-06 05:21:53 +08:00
|
|
|
|
import React, {
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
|
|
|
|
|
useLayoutEffect,
|
|
|
|
|
|
useMemo,
|
|
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
} from "react";
|
|
|
|
|
|
import { createPortal } from "react-dom";
|
2025-12-28 10:01:30 +08:00
|
|
|
|
import { createReactBlockSpec } from "@blocknote/react";
|
2026-01-07 07:25:07 +08:00
|
|
|
|
import type {
|
|
|
|
|
|
BlockConfig,
|
|
|
|
|
|
BlockNoteEditor,
|
|
|
|
|
|
DefaultInlineContentSchema,
|
|
|
|
|
|
DefaultStyleSchema,
|
|
|
|
|
|
PropSchema,
|
|
|
|
|
|
SpecificBlock,
|
|
|
|
|
|
} from "@blocknote/core";
|
2025-12-28 10:01:30 +08:00
|
|
|
|
import type { CustomBlockSchema } from "../schema";
|
|
|
|
|
|
import { MindmapToolbar } from "./MindmapToolbar";
|
2025-12-28 22:05:37 +08:00
|
|
|
|
import { MindmapSidebar } from "./MindmapSidebar";
|
|
|
|
|
|
import { MindmapSidebarTrigger } from "./MindmapSidebarTrigger";
|
|
|
|
|
|
import { MindmapNavigator } from "./MindmapNavigator";
|
|
|
|
|
|
import { MindmapMiniMap } from "./MindmapMiniMap";
|
|
|
|
|
|
import { MindmapCount } from "./MindmapCount";
|
2026-01-10 23:08:56 +08:00
|
|
|
|
import { MindmapContextMenu } from "./MindmapContextMenu";
|
2025-12-28 22:05:37 +08:00
|
|
|
|
import type { SidebarPanel } from "./mindmapSidebarConfig";
|
2026-01-07 07:25:07 +08:00
|
|
|
|
import type { MindMapNode } from "./mindmapTypes";
|
2025-12-31 19:26:38 +08:00
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
|
import { Input } from "@/components/ui/input";
|
2026-01-07 07:25:07 +08:00
|
|
|
|
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
2025-12-30 18:39:38 +08:00
|
|
|
|
import iconConfig from "./mindmapIconConfig";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
import { emitAssetsChanged } from "@/lib/events";
|
2026-02-01 08:47:40 +08:00
|
|
|
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
|
|
|
|
|
import { api } from "@/lib/convex/api";
|
|
|
|
|
|
import { useQuery } from "convex/react";
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
|
|
|
|
|
|
const loadIconModules = async () => {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const { nodeIconList } = await import("simple-mind-map/src/svg/icons.js");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const { mergerIconList } = await import("simple-mind-map/src/utils/index.js");
|
|
|
|
|
|
return { nodeIconList, mergerIconList };
|
|
|
|
|
|
};
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
// 安全获取图片尺寸
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 类型检查辅助函数
|
|
|
|
|
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
|
|
|
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
type MindMapInstance = {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
execCommand: (command: string, ...args: unknown[]) => void;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
destroy: () => void;
|
|
|
|
|
|
setData: (data: unknown) => void;
|
|
|
|
|
|
getData: (withConfig?: boolean) => unknown;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
on?: <TArgs extends unknown[]>(event: string, handler: (...args: TArgs) => void) => void;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
emit?: (event: string, ...args: unknown[]) => void;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
off?: <TArgs extends unknown[]>(event: string, handler: (...args: TArgs) => void) => void;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
setMode?: (mode: string) => void;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
command: { clearHistory: () => void };
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
2025-12-28 10:01:30 +08:00
|
|
|
|
painter?: { startPainter: () => void };
|
|
|
|
|
|
doExport?: { export: (type: string, isDownload?: boolean, name?: string) => Promise<unknown> };
|
2025-12-28 22:05:37 +08:00
|
|
|
|
width: number;
|
|
|
|
|
|
height: number;
|
|
|
|
|
|
miniMap?: unknown;
|
|
|
|
|
|
getThemeConfig: (key: string) => unknown;
|
|
|
|
|
|
setThemeConfig: (config: unknown) => void;
|
|
|
|
|
|
setTheme: (theme: string) => void;
|
|
|
|
|
|
setLayout: (layout: string) => void;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
type MindMapData = {
|
|
|
|
|
|
data: Record<string, unknown>;
|
|
|
|
|
|
children?: unknown[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export const defaultMindmapData = {
|
2025-12-28 10:01:30 +08:00
|
|
|
|
data: { text: "中心主题" },
|
|
|
|
|
|
children: [],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-07 21:21:22 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 持久化/初始化统一使用"根节点对象"作为数据载体,避免把包含额外字段的 wrapper 误传给 simple-mind-map
|
2026-01-07 21:21:22 +08:00
|
|
|
|
// 从而触发 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 将思维导图数据中的 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;
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
// 递归替换 asset:id 为签名 URL,并建立反向映射
|
|
|
|
|
|
const urlToAssetId = new Map<string, string>();
|
|
|
|
|
|
const replaceAssetIds = (node: any): any => {
|
|
|
|
|
|
if (!node) return node;
|
|
|
|
|
|
const newNode = { ...node };
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const signedUrl = urlMap.get(id);
|
|
|
|
|
|
if (signedUrl) {
|
|
|
|
|
|
urlToAssetId.set(signedUrl, id);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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 };
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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 };
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
|
|
|
|
|
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 };
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
if (Array.isArray(newNode.children)) {
|
|
|
|
|
|
newNode.children = newNode.children.map(revertNode);
|
|
|
|
|
|
}
|
|
|
|
|
|
return newNode;
|
|
|
|
|
|
};
|
|
|
|
|
|
return revertNode(data);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
// 修补 svg.js rbox 在节点未挂载时抛出的异常
|
|
|
|
|
|
const patchSvgRbox = async () => {
|
|
|
|
|
|
// 仅在浏览器环境生效
|
|
|
|
|
|
if (typeof window === "undefined") return;
|
|
|
|
|
|
const svgModule = await import("@svgdotjs/svg.js");
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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];
|
2026-01-05 06:59:54 +08:00
|
|
|
|
let warned = false;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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) {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 退化到 DOM 的 getBoundingClientRect 计算,避免初次渲染时出现 (0,0)
|
2026-01-07 07:25:07 +08:00
|
|
|
|
type SvgElementLike = { node?: Element | null; el?: Element | null };
|
|
|
|
|
|
const maybeThis = this as unknown as SvgElementLike;
|
|
|
|
|
|
const el = maybeThis?.node ?? maybeThis?.el ?? null;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
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),
|
|
|
|
|
|
};
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (!warned) warned = true;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
return fallback;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
Ctor.prototype.__wolaiRboxPatched = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const applyToActiveNodes = (
|
|
|
|
|
|
mindmap: MindMapInstance | null,
|
|
|
|
|
|
handler: (node: unknown) => void,
|
|
|
|
|
|
) => {
|
2025-12-28 22:05:37 +08:00
|
|
|
|
if (!mindmap) {
|
|
|
|
|
|
window.alert("思维导图尚未初始化");
|
2025-12-28 10:01:30 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const renderer = mindmap.renderer;
|
|
|
|
|
|
const list = renderer?.activeNodeList;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
if (!list || list.length === 0) {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 优先尝试 lastActiveNodeList(部分操作会短暂清空 activeNodeList)
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const lastActive = renderer?.lastActiveNodeList?.[0];
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const root = renderer?.root ?? renderer?.renderTree?._node;
|
|
|
|
|
|
const fallback = lastActive ?? root;
|
|
|
|
|
|
if (fallback) {
|
|
|
|
|
|
renderer?.addNodeToActiveList?.(fallback);
|
|
|
|
|
|
mindmap.execCommand?.("SET_NODE_ACTIVE", fallback, true);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
window.alert("请选择至少一个节点再执行该操作");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
(renderer?.activeNodeList ?? []).forEach(handler);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 22:13:19 +08:00
|
|
|
|
// 确保存在激活节点后再执行命令,若无则自动选中根节点
|
|
|
|
|
|
const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
|
|
|
|
|
|
const mm = mindmap ?? null;
|
|
|
|
|
|
if (!mm) {
|
|
|
|
|
|
window.alert("思维导图尚未初始化");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const renderer = mm.renderer;
|
|
|
|
|
|
const list = renderer?.activeNodeList ?? [];
|
2025-12-28 22:13:19 +08:00
|
|
|
|
if (!list || list.length === 0) {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const lastActive = renderer?.lastActiveNodeList?.[0];
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const root = renderer?.root ?? renderer?.renderTree?._node;
|
|
|
|
|
|
const fallback = lastActive ?? root;
|
|
|
|
|
|
if (fallback) {
|
|
|
|
|
|
// 主动维护激活列表,避免首次插入子节点时因无激活节点导致插入位置错误
|
|
|
|
|
|
// 先确保根节点居中,避免后续插入节点跑到 (0,0)
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 注意:setRootNodeCenter 内部依赖 renderer.root(而不是 renderTree._node)。
|
|
|
|
|
|
// 在“刚初始化/刚插入第二张导图”的窗口期里 renderer.root 可能仍是 null,
|
|
|
|
|
|
// 此时调用会触发内部解构异常,导致快捷键/工具栏命令中断。
|
|
|
|
|
|
if (renderer?.root && renderer?.setRootNodeCenter) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
renderer.setRootNodeCenter();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
mm.view?.fit?.();
|
2026-01-07 07:25:07 +08:00
|
|
|
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
2026-01-05 06:59:54 +08:00
|
|
|
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(fallback, true);
|
|
|
|
|
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(fallback);
|
|
|
|
|
|
mm.execCommand?.("SET_NODE_ACTIVE", fallback, true);
|
2025-12-28 22:13:19 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
window.alert("请先选中一个节点");
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return mm;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const MindmapBlockView = ({
|
|
|
|
|
|
block,
|
|
|
|
|
|
editor,
|
|
|
|
|
|
fullscreen = false,
|
2026-01-10 10:35:21 +08:00
|
|
|
|
onExitFullscreen,
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}: {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
block: SpecificBlock<
|
|
|
|
|
|
CustomBlockSchema,
|
|
|
|
|
|
"mindmap",
|
|
|
|
|
|
DefaultInlineContentSchema,
|
|
|
|
|
|
DefaultStyleSchema
|
|
|
|
|
|
>;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
editor: BlockNoteEditor<CustomBlockSchema>;
|
|
|
|
|
|
fullscreen?: boolean;
|
2026-01-10 10:35:21 +08:00
|
|
|
|
onExitFullscreen?: () => void;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}) => {
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const mindmapReadyRef = useRef(false);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const mindmapRef = useRef<MindMapInstance | null>(null);
|
|
|
|
|
|
const hasLocalEditsRef = useRef(false);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const lastLocalEditAtRef = useRef(0);
|
|
|
|
|
|
const markLocalEdited = useCallback(() => {
|
|
|
|
|
|
hasLocalEditsRef.current = true;
|
|
|
|
|
|
lastLocalEditAtRef.current = Date.now();
|
|
|
|
|
|
}, []);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const applyingRemoteRef = useRef(false);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const [canBack, setCanBack] = useState(false);
|
|
|
|
|
|
const [canForward, setCanForward] = useState(false);
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const [activeNodes, setActiveNodes] = useState<MindMapNode[]>([]);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const [painterMode, setPainterMode] = useState(false);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
const [showMiniMap, setShowMiniMap] = useState(false);
|
|
|
|
|
|
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
|
2025-12-30 18:39:38 +08:00
|
|
|
|
const [showNoteModal, setShowNoteModal] = useState(false);
|
|
|
|
|
|
const [noteContent, setNoteContent] = useState("");
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const [localFullscreen, setLocalFullscreen] = useState(false);
|
|
|
|
|
|
const effectiveFullscreen = fullscreen || localFullscreen;
|
|
|
|
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
const hotkeyScopeRef = useRef(false);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
const lastInteractionAtRef = useRef(0);
|
|
|
|
|
|
const skipNextPasteRef = useRef(false);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const recentNodeDblclickRef = useRef(false);
|
2026-01-07 22:06:49 +08:00
|
|
|
|
const deletingRef = useRef(false);
|
2026-01-10 12:01:38 +08:00
|
|
|
|
const textEditOpenRef = useRef(false);
|
|
|
|
|
|
const suppressTextEditRefocusUntilRef = useRef(0);
|
|
|
|
|
|
const lastTextEditRefocusAtRef = useRef(0);
|
|
|
|
|
|
const pendingInitActivateRootRef = useRef(false);
|
|
|
|
|
|
const pendingRenderEndActivateRootRef = useRef(false);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 存储 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>());
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
|
|
|
|
|
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
|
|
|
|
|
const instance = mm ?? mindmap;
|
|
|
|
|
|
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
|
|
|
|
|
|
}, [mindmap]);
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const docId = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
block.props.docId ||
|
|
|
|
|
|
(typeof window !== "undefined"
|
|
|
|
|
|
? window.location.pathname.split("/").pop() ?? ""
|
|
|
|
|
|
: ""),
|
2026-01-07 07:25:07 +08:00
|
|
|
|
[block.props.docId],
|
2026-01-02 07:25:50 +08:00
|
|
|
|
);
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const mindmapId = block.id;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 获取 workspaceId(用于上传图片)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
if (!docId) return;
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
|
try {
|
2026-02-01 08:47:40 +08:00
|
|
|
|
// 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。
|
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
|
`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`,
|
|
|
|
|
|
);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-06 05:21:53 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
hasLocalEditsRef.current = false;
|
2026-02-01 08:47:40 +08:00
|
|
|
|
lastLocalEditAtRef.current = 0;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
applyingRemoteRef.current = false;
|
|
|
|
|
|
}, [docId]);
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const autosaveKey = useMemo(() => {
|
|
|
|
|
|
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
|
|
|
|
|
|
return `${STORAGE_PREFIX}${mindmapId}`;
|
|
|
|
|
|
}, [docId, mindmapId]);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
|
|
|
|
|
|
// 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。
|
|
|
|
|
|
const lastLocalSavedAtRef = useRef<string | null>(null);
|
|
|
|
|
|
const lastAppliedRemoteUpdatedAtRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const remoteMindmap = useQuery(
|
|
|
|
|
|
api.mindmaps.get,
|
|
|
|
|
|
isConvexEnabled() && docId ? { docId, mindmapId } : "skip",
|
|
|
|
|
|
);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const initialDataRef = useRef<unknown>(null);
|
|
|
|
|
|
if (initialDataRef.current === null) {
|
|
|
|
|
|
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
|
|
|
|
|
|
if (cached) {
|
|
|
|
|
|
try {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
initialDataRef.current = canonicalizeMindmapData(JSON.parse(cached));
|
2025-12-28 10:01:30 +08:00
|
|
|
|
} catch {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
initialDataRef.current = canonicalizeMindmapData(block.props.data ?? defaultMindmapData);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
// 优先加载本地文件,其次 Supabase(通过后端 API)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
if (!docId) return;
|
|
|
|
|
|
(async () => {
|
|
|
|
|
|
try {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
// 多导图:按 docId + mindmapId 拉取
|
|
|
|
|
|
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (!resp.ok) return;
|
|
|
|
|
|
const payload = await resp.json().catch(() => null);
|
|
|
|
|
|
const data = payload?.data;
|
|
|
|
|
|
if (!data || cancelled) return;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
|
|
|
|
|
|
if (hasLocalEditsRef.current) return;
|
2026-01-07 21:21:22 +08:00
|
|
|
|
initialDataRef.current = canonicalizeMindmapData(data);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (mindmap) {
|
2026-01-06 05:21:53 +08:00
|
|
|
|
applyingRemoteRef.current = true;
|
|
|
|
|
|
try {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
mindmap.setData(initialDataRef.current);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
mindmap.command.clearHistory();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
applyingRemoteRef.current = false;
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
})();
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true;
|
|
|
|
|
|
};
|
2026-01-08 06:28:14 +08:00
|
|
|
|
}, [docId, mindmap, mindmapId]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
|
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!remoteMindmap || typeof remoteMindmap !== "object") return;
|
|
|
|
|
|
|
|
|
|
|
|
const meta = (remoteMindmap as any).meta as Record<string, unknown> | 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;
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!effectiveFullscreen) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
editor.updateBlock(block, { props: { ...block.props, data: incoming } });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (mindmap) {
|
|
|
|
|
|
applyingRemoteRef.current = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
mindmap.setData(incoming);
|
|
|
|
|
|
mindmap.command.clearHistory();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
applyingRemoteRef.current = false;
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [
|
|
|
|
|
|
autosaveKey,
|
|
|
|
|
|
block,
|
|
|
|
|
|
docId,
|
|
|
|
|
|
editor,
|
|
|
|
|
|
effectiveFullscreen,
|
|
|
|
|
|
mindmap,
|
|
|
|
|
|
mindmapId,
|
|
|
|
|
|
onExitFullscreen,
|
|
|
|
|
|
remoteMindmap,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-01-06 05:21:53 +08:00
|
|
|
|
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const onPointerDownCapture = (e: Event) => {
|
|
|
|
|
|
const wrapper = wrapperRef.current;
|
|
|
|
|
|
if (!wrapper) return;
|
2026-01-09 07:22:27 +08:00
|
|
|
|
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,保证快捷键/粘贴作用域稳定。
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if (effectiveFullscreen) return;
|
2026-01-09 07:22:27 +08:00
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 0);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
};
|
2026-01-09 07:22:27 +08:00
|
|
|
|
document.addEventListener("pointerdown", onPointerDownCapture, true);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
document.addEventListener("mousedown", onPointerDownCapture, true);
|
|
|
|
|
|
return () => {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
|
|
|
|
|
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
};
|
2026-01-10 10:35:21 +08:00
|
|
|
|
}, [effectiveFullscreen]);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 关键:阻止鼠标事件冒泡到 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();
|
|
|
|
|
|
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 内嵌视图:同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定。
|
|
|
|
|
|
// 全屏(Portal)视图:不能强制 focus wrapper,否则会打断节点双击编辑输入框的焦点。
|
|
|
|
|
|
if (!effectiveFullscreen) {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-10 12:01:38 +08:00
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
2026-01-09 07:22:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
wrapper.addEventListener("mousedown", stopBubble);
|
|
|
|
|
|
wrapper.addEventListener("pointerdown", stopBubble);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
wrapper.removeEventListener("mousedown", stopBubble);
|
|
|
|
|
|
wrapper.removeEventListener("pointerdown", stopBubble);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [effectiveFullscreen]);
|
|
|
|
|
|
|
2026-01-06 05:21:53 +08:00
|
|
|
|
// 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 记录“是否处于节点文本编辑态”,并在全屏时做一次“焦点保护”:避免外部组件/浏览器机制
|
|
|
|
|
|
// 在延迟(例如 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]);
|
|
|
|
|
|
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const exitLocalFullscreen = useCallback(() => {
|
|
|
|
|
|
setActiveSidebar(null);
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if (fullscreen) {
|
|
|
|
|
|
onExitFullscreen?.();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
if (typeof document === "undefined") {
|
|
|
|
|
|
setLocalFullscreen(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (document.fullscreenElement) {
|
|
|
|
|
|
document
|
|
|
|
|
|
.exitFullscreen()
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => setLocalFullscreen(false));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setLocalFullscreen(false);
|
2026-01-10 10:35:21 +08:00
|
|
|
|
}, [fullscreen, onExitFullscreen]);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
const inScope =
|
|
|
|
|
|
hotkeyScopeRef.current ||
|
|
|
|
|
|
(lastInteractionAtRef.current > 0 &&
|
|
|
|
|
|
Date.now() - lastInteractionAtRef.current < 60_000);
|
|
|
|
|
|
if (!inScope) return;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const mm = mindmapRef.current;
|
|
|
|
|
|
if (!mm || !mindmapReadyRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
const target = e.target as HTMLElement | null;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const editClasses = mm.editNodeClassList;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const wrapper = wrapperRef.current;
|
|
|
|
|
|
const isInWrapper = Boolean(wrapper && target && wrapper.contains(target));
|
2026-01-09 07:22:27 +08:00
|
|
|
|
const isNodeTextEditing = (() => {
|
|
|
|
|
|
if (!target || !isInWrapper) return false;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const tag = target.tagName;
|
2026-01-09 07:22:27 +08:00
|
|
|
|
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
|
|
|
|
|
// 仅在 simple-mind-map 的“节点文本编辑元素”上判定为编辑态;不要用 isContentEditable 泛化判断,
|
|
|
|
|
|
// 否则会把库内部的隐藏输入层误判成编辑态,导致 Ctrl+C/Ctrl+V 失效。
|
|
|
|
|
|
if (editClasses) {
|
2026-01-06 05:21:53 +08:00
|
|
|
|
for (const cls of editClasses) {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
if (!cls) continue;
|
|
|
|
|
|
if (target.classList?.contains(cls)) return true;
|
|
|
|
|
|
const found = target.closest?.(`.${cls}`);
|
|
|
|
|
|
if (found) return true;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-09 07:22:27 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
})();
|
2026-01-06 05:21:53 +08:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
|
|
|
|
|
|
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
|
|
|
|
|
|
// 切换全屏/刷新后不会丢失。
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) persistDataRef.current?.(snapshot);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
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]);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) persistDataRef.current?.(snapshot);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 节点文本编辑中:只接管 Enter/Tab;其余快捷键交给默认输入行为
|
|
|
|
|
|
//(例如 Ctrl+C/Ctrl+V 作为文本复制粘贴)。
|
|
|
|
|
|
if (isNodeTextEditing) return;
|
|
|
|
|
|
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if ((key === "Delete" || key === "Backspace") && !e.shiftKey && !e.altKey && !isMod) {
|
|
|
|
|
|
stop();
|
|
|
|
|
|
const inst = ensureActiveBefore(mm);
|
|
|
|
|
|
if (!inst) return;
|
|
|
|
|
|
inst.execCommand?.("REMOVE_NODE");
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-10 10:35:21 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) persistDataRef.current?.(snapshot);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 05:21:53 +08:00
|
|
|
|
if (isMod && lower === "c") {
|
|
|
|
|
|
stop();
|
|
|
|
|
|
const inst = ensureActiveBefore(mm);
|
|
|
|
|
|
if (!inst) return;
|
2026-01-09 07:22:27 +08:00
|
|
|
|
const node = pickTargetNode(inst);
|
|
|
|
|
|
if (node) {
|
|
|
|
|
|
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
|
|
|
|
|
}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
inst.renderer.copy?.();
|
2026-01-06 05:21:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isMod && lower === "v") {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 兜底:某些场景下(尤其是嵌入编辑器时)浏览器仍会触发原生 paste 事件。
|
|
|
|
|
|
// 这里标记一次,避免我们在 paste capture 里重复执行粘贴逻辑。
|
|
|
|
|
|
skipNextPasteRef.current = true;
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
skipNextPasteRef.current = false;
|
|
|
|
|
|
}, 200);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
stop();
|
|
|
|
|
|
const inst = ensureActiveBefore(mm);
|
|
|
|
|
|
if (!inst) return;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const renderer = inst.renderer;
|
|
|
|
|
|
const copyData = renderer.beingCopyData ?? null;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
if (copyData) {
|
|
|
|
|
|
inst.execCommand?.("PASTE_NODE", copyData);
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) persistDataRef.current?.(snapshot);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
renderer.paste?.();
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) persistDataRef.current?.(snapshot);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.addEventListener("keydown", onKeyDownCapture, true);
|
|
|
|
|
|
return () => {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
window.removeEventListener("keydown", onKeyDownCapture, true);
|
|
|
|
|
|
};
|
2026-02-01 08:47:40 +08:00
|
|
|
|
}, [markLocalEdited]);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 兜底:在非全屏嵌入 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底持久化:避免快速切换视图导致“看起来没保存”
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-02-01 08:47:40 +08:00
|
|
|
|
}, [markLocalEdited]);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 兜底: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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
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);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
};
|
2026-02-01 08:47:40 +08:00
|
|
|
|
}, [markLocalEdited]);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const persistData = useCallback(
|
|
|
|
|
|
(data: unknown) => {
|
|
|
|
|
|
if (!editor) return;
|
2026-01-07 21:21:22 +08:00
|
|
|
|
// 关键:全屏/非全屏切换会重建 mindmap 实例,初始化数据必须跟随最新编辑
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 否则切换视图时会用旧数据,表现为"看起来没保存"
|
|
|
|
|
|
let safe = canonicalizeMindmapData(data);
|
|
|
|
|
|
// 将 signed URL 转换回 asset:id 格式用于保存
|
|
|
|
|
|
if (signedUrlToAssetIdRef.current.size > 0) {
|
|
|
|
|
|
safe = revertToAssetIds(safe, signedUrlToAssetIdRef.current);
|
|
|
|
|
|
}
|
2026-01-07 21:21:22 +08:00
|
|
|
|
initialDataRef.current = safe;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror
|
|
|
|
|
|
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 被迫退出(表现为"有光标但过一会儿就消失")。
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
|
|
|
|
|
|
// 时再统一把最新数据写回 block。
|
|
|
|
|
|
if (!effectiveFullscreen) {
|
|
|
|
|
|
editor.updateBlock(block, { props: { ...block.props, data: safe } });
|
|
|
|
|
|
}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (docId) {
|
2026-02-01 08:47:40 +08:00
|
|
|
|
// 同步到本地文件 + Convex(弱依赖)
|
|
|
|
|
|
const requestStartedAt = Date.now();
|
2026-01-08 06:28:14 +08:00
|
|
|
|
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-01-07 21:21:22 +08:00
|
|
|
|
body: JSON.stringify({ data: safe }),
|
2026-01-02 07:25:50 +08:00
|
|
|
|
})
|
2026-02-01 08:47:40 +08:00
|
|
|
|
.then(async (resp) => {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (resp.ok) {
|
2026-02-01 08:47:40 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const payload = (await resp.json().catch(() => null)) as any;
|
|
|
|
|
|
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
|
|
|
|
|
|
if (updatedAt) {
|
|
|
|
|
|
lastLocalSavedAtRef.current = updatedAt;
|
|
|
|
|
|
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
|
|
|
|
|
|
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
|
|
|
|
|
// 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了”
|
|
|
|
|
|
if (lastLocalEditAtRef.current <= requestStartedAt) {
|
|
|
|
|
|
hasLocalEditsRef.current = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const fileName = `mindmap-${mindmapId}.json`;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
emitAssetsChanged(docId, {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
id: mindmapId,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
document_id: docId,
|
|
|
|
|
|
asset_type: "mindmap",
|
2026-01-08 06:28:14 +08:00
|
|
|
|
file_name: fileName,
|
|
|
|
|
|
file_url: `/documents/${docId}/${fileName}`,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
});
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-01-11 12:35:53 +08:00
|
|
|
|
.catch(() => {});
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
2025-12-28 10:01:30 +08:00
|
|
|
|
},
|
2026-01-10 12:01:38 +08:00
|
|
|
|
[autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen],
|
2025-12-28 10:01:30 +08:00
|
|
|
|
);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
persistDataRef.current = persistData;
|
|
|
|
|
|
}, [persistData]);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// “有上限的防抖保存”:连续 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]);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const initialSyncDone = useRef(false);
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!docId || !mindmap || initialSyncDone.current) return;
|
|
|
|
|
|
initialSyncDone.current = true;
|
2026-01-07 21:21:22 +08:00
|
|
|
|
const data = canonicalizeMindmapData(
|
|
|
|
|
|
mindmap.getData?.(true) ?? mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData,
|
|
|
|
|
|
);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
(async () => {
|
|
|
|
|
|
try {
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-01-10 10:35:21 +08:00
|
|
|
|
body: JSON.stringify({ data, createOnly: true }),
|
2026-01-05 06:59:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
if (!resp.ok) {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {} finally {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const fileName = `mindmap-${mindmapId}.json`;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
emitAssetsChanged(docId, {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
id: mindmapId,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
document_id: docId,
|
|
|
|
|
|
asset_type: "mindmap",
|
2026-01-08 06:28:14 +08:00
|
|
|
|
file_name: fileName,
|
|
|
|
|
|
file_url: `/documents/${docId}/${fileName}`,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-01-08 06:28:14 +08:00
|
|
|
|
}, [docId, mindmap, mindmapId, initialDataRef]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2025-12-28 22:05:37 +08:00
|
|
|
|
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!mindmap || activeNodes.length > 0) return;
|
|
|
|
|
|
const rootNode = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node;
|
|
|
|
|
|
if (rootNode) {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
setActiveNodes([rootNode as MindMapNode]);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
mindmap.renderer.activeNodeList = [rootNode];
|
|
|
|
|
|
mindmap.renderer.lastActiveNodeList = [rootNode];
|
|
|
|
|
|
mindmap.emit?.("node_active", rootNode, [rootNode]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [mindmap, activeNodes.length]);
|
|
|
|
|
|
|
2026-01-08 06:28:14 +08:00
|
|
|
|
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap-<id>.json
|
2026-01-05 06:59:54 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!docId || !mindmap) return;
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const fileName = `mindmap-${mindmapId}.json`;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
emitAssetsChanged(docId, {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
id: mindmapId,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
document_id: docId,
|
|
|
|
|
|
asset_type: "mindmap",
|
2026-01-08 06:28:14 +08:00
|
|
|
|
file_name: fileName,
|
|
|
|
|
|
file_url: `/documents/${docId}/${fileName}`,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
});
|
2026-01-08 06:28:14 +08:00
|
|
|
|
}, [docId, mindmap, mindmapId]);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let destroyed = false;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
let createdInstance: MindMapInstance | null = null;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
(async () => {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
// 进入/退出全屏会切换渲染树,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;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const [
|
|
|
|
|
|
{ default: MindMap },
|
|
|
|
|
|
{ default: Painter },
|
|
|
|
|
|
{ default: AssociativeLine },
|
|
|
|
|
|
{ default: OuterFrame },
|
|
|
|
|
|
{ default: Exporter },
|
|
|
|
|
|
{ default: Formula },
|
|
|
|
|
|
{ default: RichText },
|
2025-12-28 22:05:37 +08:00
|
|
|
|
{ default: MiniMapPlugin },
|
|
|
|
|
|
{ default: Select },
|
|
|
|
|
|
{ default: Drag },
|
|
|
|
|
|
{ default: KeyboardNavigation },
|
2025-12-31 19:26:38 +08:00
|
|
|
|
{ default: NodeImgAdjust },
|
2026-01-02 07:25:50 +08:00
|
|
|
|
{ default: Scrollbar },
|
|
|
|
|
|
{ default: RainbowLines },
|
|
|
|
|
|
{ default: Watermark },
|
|
|
|
|
|
{ default: TouchEvent },
|
|
|
|
|
|
{ default: Cooperate },
|
|
|
|
|
|
{ default: Demonstrate },
|
|
|
|
|
|
{ default: MindMapLayoutPro },
|
|
|
|
|
|
{ default: NodeBase64ImageStorage },
|
|
|
|
|
|
{ default: ExportPDF },
|
|
|
|
|
|
{ default: ExportXMind },
|
2026-01-05 06:59:54 +08:00
|
|
|
|
MindMapNodeModule,
|
2025-12-28 10:01:30 +08:00
|
|
|
|
] = 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"),
|
2025-12-28 22:05:37 +08:00
|
|
|
|
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"),
|
2025-12-31 19:26:38 +08:00
|
|
|
|
import("simple-mind-map/src/plugins/NodeImgAdjust.js"),
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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"),
|
2026-01-05 06:59:54 +08:00
|
|
|
|
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
|
2026-01-02 07:25:50 +08:00
|
|
|
|
import("simple-mind-map/src/plugins/ExportPDF.js"),
|
|
|
|
|
|
import("simple-mind-map/src/plugins/ExportXMind.js"),
|
2026-01-05 06:59:54 +08:00
|
|
|
|
import("simple-mind-map/src/core/render/node/MindMapNode.js"),
|
2025-12-28 10:01:30 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// React StrictMode(开发环境)会触发 effect 的“挂载-卸载-再挂载”流程:
|
|
|
|
|
|
// 若异步加载完成时已经卸载,不应再创建实例,否则可能留下残余 DOM,导致出现多个 root。
|
|
|
|
|
|
if (destroyed) return;
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底修补 simple-mind-map 在创建富文本节点时 host/缓存未就绪导致的空指针
|
2026-01-07 07:25:07 +08:00
|
|
|
|
type MindMapNodeCtorLike = {
|
|
|
|
|
|
prototype: {
|
|
|
|
|
|
__wolaiRichtextPatched?: boolean;
|
|
|
|
|
|
createRichTextNode?: (...args: unknown[]) => unknown;
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
const MindMapNodeCtor = (MindMapNodeModule as unknown as { default?: MindMapNodeCtorLike })?.default;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
if (
|
|
|
|
|
|
MindMapNodeCtor &&
|
|
|
|
|
|
!MindMapNodeCtor.prototype.__wolaiRichtextPatched
|
|
|
|
|
|
) {
|
|
|
|
|
|
const originalCreate = MindMapNodeCtor.prototype.createRichTextNode;
|
|
|
|
|
|
MindMapNodeCtor.prototype.__wolaiRichtextPatched = true;
|
|
|
|
|
|
MindMapNodeCtor.prototype.createRichTextNode = function patched(...args: unknown[]) {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
type RichtextThis = {
|
|
|
|
|
|
mindMap?: { el?: HTMLElement | null; commonCaches?: Record<string, unknown> } | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
const self = this as unknown as RichtextThis;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// host 兜底:优先使用实例容器,否则退回 body
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const host: HTMLElement = (self?.mindMap?.el as HTMLElement | null) ?? document.body;
|
|
|
|
|
|
if (!self.mindMap) {
|
|
|
|
|
|
self.mindMap = { el: host, commonCaches: {} };
|
2026-01-05 06:59:54 +08:00
|
|
|
|
}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const el = self.mindMap.el;
|
|
|
|
|
|
if (!el || typeof el.appendChild !== "function") {
|
|
|
|
|
|
self.mindMap.el = host;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const caches = self.mindMap.commonCaches ?? (self.mindMap.commonCaches = {});
|
|
|
|
|
|
const measureKey = "measureRichtextNodeTextSizeEl";
|
|
|
|
|
|
if (!caches[measureKey]) {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const measureDiv = document.createElement("div");
|
|
|
|
|
|
measureDiv.style.position = "fixed";
|
|
|
|
|
|
measureDiv.style.left = "-999999px";
|
2026-01-07 07:25:07 +08:00
|
|
|
|
(self.mindMap.el ?? document.body).appendChild(measureDiv);
|
|
|
|
|
|
caches[measureKey] = measureDiv;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (typeof originalCreate === "function") {
|
|
|
|
|
|
return originalCreate.apply(this, args);
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-28 22:05:37 +08:00
|
|
|
|
const plugins = [
|
2025-12-28 10:01:30 +08:00
|
|
|
|
{ name: "Painter", plugin: Painter },
|
|
|
|
|
|
{ name: "AssociativeLine", plugin: AssociativeLine },
|
|
|
|
|
|
{ name: "OuterFrame", plugin: OuterFrame },
|
|
|
|
|
|
{ name: "Export", plugin: Exporter },
|
|
|
|
|
|
{ name: "Formula", plugin: Formula },
|
|
|
|
|
|
{ name: "RichText", plugin: RichText },
|
2025-12-28 22:05:37 +08:00
|
|
|
|
{ name: "MiniMap", plugin: MiniMapPlugin },
|
|
|
|
|
|
{ name: "Select", plugin: Select },
|
|
|
|
|
|
{ name: "Drag", plugin: Drag },
|
|
|
|
|
|
{ name: "KeyboardNavigation", plugin: KeyboardNavigation },
|
2025-12-31 19:26:38 +08:00
|
|
|
|
{ name: "NodeImgAdjust", plugin: NodeImgAdjust },
|
2026-01-02 07:25:50 +08:00
|
|
|
|
{ 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 },
|
2025-12-28 22:05:37 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
plugins.forEach(({ name, plugin }) => {
|
2025-12-28 10:01:30 +08:00
|
|
|
|
if (!plugin) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const { nodeIconList, mergerIconList } = await loadIconModules();
|
|
|
|
|
|
|
|
|
|
|
|
await patchSvgRbox();
|
|
|
|
|
|
|
2026-01-05 06:59:54 +08:00
|
|
|
|
if (destroyed) return;
|
|
|
|
|
|
|
|
|
|
|
|
const hostEl = hostContainer;
|
|
|
|
|
|
// 防止同一容器残留旧实例的 svg/dom(StrictMode 或异常 destroy 场景)
|
|
|
|
|
|
try {
|
|
|
|
|
|
hostEl.replaceChildren();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
hostEl.innerHTML = "";
|
|
|
|
|
|
}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2026-01-07 21:21:22 +08:00
|
|
|
|
// 全屏/非全屏切换会重建实例:创建新实例前优先从本地缓存读取最新数据
|
|
|
|
|
|
// 以避免“全屏里编辑 → 退出全屏后内容消失 / 反之亦然”。
|
|
|
|
|
|
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;
|
|
|
|
|
|
})();
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const dataForInitCanonical = canonicalizeMindmapData(rawForInit);
|
|
|
|
|
|
initialDataRef.current = dataForInitCanonical;
|
|
|
|
|
|
// 将 asset:id 转换为签名 URL,并获取映射
|
|
|
|
|
|
const { data: dataForInit, urlToAssetId } = await resolveAssetUrls(dataForInitCanonical);
|
|
|
|
|
|
// 保存映射供后续保存时使用
|
|
|
|
|
|
signedUrlToAssetIdRef.current = urlToAssetId;
|
2026-01-07 21:21:22 +08:00
|
|
|
|
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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[];
|
2026-01-10 12:01:38 +08:00
|
|
|
|
customInnerElsAppendTo?: HTMLElement;
|
|
|
|
|
|
nodeTextEditZIndex?: number;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
}) => MindMapInstance;
|
2026-01-10 12:01:38 +08:00
|
|
|
|
const MindMapCtor = MindMap as unknown as MindMapConstructor;
|
|
|
|
|
|
|
|
|
|
|
|
// Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响:
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 若 z-index 低于我们的全屏覆盖层,会出现"进入编辑态但看不到独立输入框/光标"的问题。
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。
|
|
|
|
|
|
const fullscreenTextEditOptions = effectiveFullscreen
|
|
|
|
|
|
? {
|
|
|
|
|
|
// 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。
|
|
|
|
|
|
// 注意:如果把编辑框 append 到 wrapper(overflow-hidden),可能会影响 box-shadow/可见性。
|
|
|
|
|
|
nodeTextEditZIndex: 100000,
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 将图片调整遮罩挂载到容器内,确保全屏下可见且事件正常
|
|
|
|
|
|
customInnerElsAppendTo: containerRef.current || undefined,
|
2026-01-10 12:01:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
: null;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
|
|
|
|
|
|
const instance = new MindMapCtor({
|
2026-01-02 07:25:50 +08:00
|
|
|
|
el: hostEl,
|
2026-01-07 21:21:22 +08:00
|
|
|
|
data: dataForInit,
|
2025-12-28 10:01:30 +08:00
|
|
|
|
theme: "classic",
|
|
|
|
|
|
layout: "logicalStructure",
|
|
|
|
|
|
mousewheelAction: "zoom",
|
|
|
|
|
|
enableFreeDrag: true,
|
|
|
|
|
|
enableCtrlKeyNodeSelection: true,
|
|
|
|
|
|
fit: true,
|
2025-12-28 22:05:37 +08:00
|
|
|
|
useLeftKeySelectionRightKeyDrag: true,
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 新建节点默认激活,编辑由我们手动触发,避免初次插入时定位到 (0,0)
|
|
|
|
|
|
createNewNodeBehavior: "activeOnly",
|
2025-12-30 18:39:38 +08:00
|
|
|
|
// 传入扩展图标表,和官方一致
|
|
|
|
|
|
iconList: mergerIconList([
|
|
|
|
|
|
...nodeIconList,
|
|
|
|
|
|
...(iconConfig as unknown[]),
|
|
|
|
|
|
]),
|
2026-01-10 12:01:38 +08:00
|
|
|
|
...(fullscreenTextEditOptions ?? {}),
|
2026-01-07 07:25:07 +08:00
|
|
|
|
});
|
2026-01-05 06:59:54 +08:00
|
|
|
|
createdInstance = instance;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
mindmapRef.current = instance;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用
|
|
|
|
|
|
//(render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。
|
|
|
|
|
|
mindmapReadyRef.current = true;
|
|
|
|
|
|
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
|
2026-01-07 07:25:07 +08:00
|
|
|
|
type MindMapWithCaches = MindMapInstance & { commonCaches?: Record<string, unknown> };
|
|
|
|
|
|
const instanceWithCaches = instance as MindMapWithCaches;
|
|
|
|
|
|
if (!instanceWithCaches.commonCaches) {
|
|
|
|
|
|
instanceWithCaches.commonCaches = {};
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const measureKey = "measureRichtextNodeTextSizeEl";
|
|
|
|
|
|
if (!instanceWithCaches.commonCaches[measureKey]) {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const measureDiv = document.createElement("div");
|
|
|
|
|
|
measureDiv.style.position = "fixed";
|
|
|
|
|
|
measureDiv.style.left = "-999999px";
|
2026-01-07 07:25:07 +08:00
|
|
|
|
instanceWithCaches.commonCaches[measureKey] = measureDiv;
|
|
|
|
|
|
hostEl.appendChild(measureDiv);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
instance.setMode?.("edit");
|
2026-01-09 07:22:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 兜底:在某些环境/焦点状态下,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)
|
|
|
|
|
|
) {
|
2026-02-01 08:47:40 +08:00
|
|
|
|
markLocalEdited();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const snapshot =
|
|
|
|
|
|
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) persistDataRef.current?.(snapshot);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ret;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const centerAndFit = (retry = 0) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const hostRect = hostEl.getBoundingClientRect?.();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
if (!hostRect || hostRect.width < 10 || hostRect.height < 10) {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
if (retry < 5) {
|
|
|
|
|
|
window.setTimeout(() => centerAndFit(retry + 1), 80);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 只要容器尺寸已可用,就允许工具栏命令执行;render_end 可能早于监听注册触发
|
|
|
|
|
|
//(尤其在首次构造时同步渲染),因此不要完全依赖 render_end 来标记就绪。
|
|
|
|
|
|
mindmapReadyRef.current = true;
|
|
|
|
|
|
const renderer = instance.renderer;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const rootNode = renderer?.root ?? renderer?.renderTree?._node;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
if (rootNode && renderer?.setRootNodeCenter) {
|
|
|
|
|
|
renderer.setRootNodeCenter();
|
|
|
|
|
|
}
|
|
|
|
|
|
instance.view?.fit?.();
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
if (retry < 3) {
|
|
|
|
|
|
window.setTimeout(() => centerAndFit(retry + 1), 50);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
window.requestAnimationFrame(() => centerAndFit());
|
|
|
|
|
|
window.setTimeout(() => centerAndFit(), 80);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
// 便于开发阶段在控制台直接调试实例
|
|
|
|
|
|
window.__mindmapInstance = instance;
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const w = window as unknown as {
|
|
|
|
|
|
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
2026-01-10 10:35:21 +08:00
|
|
|
|
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
2026-01-08 06:28:14 +08:00
|
|
|
|
};
|
|
|
|
|
|
if (!w.__mindmapInstancesById) w.__mindmapInstancesById = {};
|
|
|
|
|
|
w.__mindmapInstancesById[mindmapId] = instance;
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if (!w.__mindmapPersistById) w.__mindmapPersistById = {};
|
|
|
|
|
|
w.__mindmapPersistById[mindmapId] = (data: unknown) => {
|
|
|
|
|
|
persistDataRef.current?.(data);
|
|
|
|
|
|
};
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setMindmap(instance);
|
|
|
|
|
|
|
|
|
|
|
|
instance.on?.("back_forward", (index: number, len: number) => {
|
|
|
|
|
|
setCanBack(index > 0);
|
|
|
|
|
|
setCanForward(index < len - 1);
|
|
|
|
|
|
});
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 渲染完成后再二次居中,并确保根节点被标记为激活
|
|
|
|
|
|
const handleRenderEnd = () => {
|
|
|
|
|
|
const renderer = instance.renderer;
|
|
|
|
|
|
const root = getRootNode(instance);
|
|
|
|
|
|
if (root) {
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 用户正在编辑节点文本时,不要清空 active 列表/切回根节点,避免打断编辑(光标/输入框消失)。
|
|
|
|
|
|
if (textEditOpenRef.current) {
|
|
|
|
|
|
pendingRenderEndActivateRootRef.current = true;
|
|
|
|
|
|
mindmapReadyRef.current = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
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);
|
2026-01-07 07:25:07 +08:00
|
|
|
|
instance.on?.("node_active", (_node: unknown, list: unknown[]) => {
|
2025-12-28 22:05:37 +08:00
|
|
|
|
if (!list || list.length === 0) return;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
setActiveNodes((list || []) as MindMapNode[]);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 兜底:部分场景下点击节点不会触发外层 pointerdown(被 stopPropagation),
|
|
|
|
|
|
// 这里用内部事件标记“当前在思维导图作用域内”,确保 Ctrl+C/Ctrl+V 不会被 BlockNote 抢走。
|
|
|
|
|
|
hotkeyScopeRef.current = true;
|
|
|
|
|
|
lastInteractionAtRef.current = Date.now();
|
2026-01-10 10:35:21 +08:00
|
|
|
|
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
|
|
|
|
|
|
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
|
|
|
|
|
|
if (!effectiveFullscreen) {
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
});
|
|
|
|
|
|
instance.on?.("node_click", (node: unknown) => {
|
2026-01-09 07:22:27 +08:00
|
|
|
|
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const renderer = instance.renderer;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const nodeWithActive = node as unknown as { active?: () => void };
|
|
|
|
|
|
if (typeof nodeWithActive.active === "function") {
|
|
|
|
|
|
nodeWithActive.active();
|
2025-12-28 22:05:37 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
// 兜底:手动维护激活列表
|
2026-01-07 07:25:07 +08:00
|
|
|
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
|
|
|
|
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(node, true);
|
|
|
|
|
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(node);
|
2025-12-28 22:05:37 +08:00
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const list = renderer?.activeNodeList ?? [];
|
2026-01-07 07:25:07 +08:00
|
|
|
|
setActiveNodes(
|
|
|
|
|
|
list.length === 0 && node
|
|
|
|
|
|
? [node as MindMapNode]
|
|
|
|
|
|
: (list as MindMapNode[]),
|
|
|
|
|
|
);
|
2026-01-09 07:22:27 +08:00
|
|
|
|
hotkeyScopeRef.current = true;
|
|
|
|
|
|
lastInteractionAtRef.current = Date.now();
|
2026-01-10 10:35:21 +08:00
|
|
|
|
// 仅内嵌视图需要把焦点从编辑器拉回本块;全屏(Portal)下强制 focus
|
|
|
|
|
|
// 可能会抢走节点文本编辑的输入焦点,导致双击编辑不出光标。
|
|
|
|
|
|
if (!effectiveFullscreen) {
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 0);
|
|
|
|
|
|
}
|
2025-12-28 10:01:30 +08:00
|
|
|
|
});
|
|
|
|
|
|
instance.on?.("painter_start", () => setPainterMode(true));
|
|
|
|
|
|
instance.on?.("painter_end", () => setPainterMode(false));
|
2026-02-01 08:47:40 +08:00
|
|
|
|
instance.on?.("data_change", () => {
|
|
|
|
|
|
if (!applyingRemoteRef.current) {
|
|
|
|
|
|
markLocalEdited();
|
|
|
|
|
|
}
|
|
|
|
|
|
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
|
|
|
|
|
|
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
|
|
|
|
|
|
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
|
2026-01-07 21:21:22 +08:00
|
|
|
|
const snapshot =
|
2026-01-09 07:22:27 +08:00
|
|
|
|
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
|
|
|
|
|
if (snapshot) schedulePersist(snapshot);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
});
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const renderer = instance.renderer;
|
2025-12-28 22:05:37 +08:00
|
|
|
|
const rootNode = getRootNode(instance);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
if (rootNode) {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 初始化时主动标记根节点为选中,确保后续插入子节点有合法的父节点
|
2026-01-07 07:25:07 +08:00
|
|
|
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
|
|
|
|
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(rootNode, true);
|
|
|
|
|
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(rootNode);
|
|
|
|
|
|
setActiveNodes([rootNode as MindMapNode]);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
|
|
|
|
|
|
window.setTimeout(() => {
|
2026-01-10 12:01:38 +08:00
|
|
|
|
// 全屏下快速进入节点编辑态时,兜底激活会打断编辑;延后到 hide_text_edit 再处理。
|
|
|
|
|
|
if (textEditOpenRef.current) {
|
|
|
|
|
|
pendingInitActivateRootRef.current = true;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const root = getRootNode(instance);
|
|
|
|
|
|
if (!root) return;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
|
|
|
|
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true);
|
|
|
|
|
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
|
|
|
|
|
|
setActiveNodes([root as MindMapNode]);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
}, 120);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
|
|
|
|
|
if (destroyed) {
|
|
|
|
|
|
instance.destroy();
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
destroyed = true;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
try {
|
2026-01-06 05:21:53 +08:00
|
|
|
|
// 切换“内嵌/全屏”会导致实例重建:这里尽量在销毁前同步一次数据,避免丢失最后一次编辑
|
2026-01-07 22:06:49 +08:00
|
|
|
|
const skipPersist =
|
|
|
|
|
|
deletingRef.current ||
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
if (!docId || typeof window === "undefined") return false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const w = window as unknown as {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
__wolaiMindmapDeletingKeys?: Set<string>;
|
2026-01-07 22:06:49 +08:00
|
|
|
|
};
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const key = `${docId}:${mindmapId}`;
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
w.__wolaiMindmapDeletingKeys?.has(docId) ||
|
|
|
|
|
|
w.__wolaiMindmapDeletingKeys?.has(key),
|
|
|
|
|
|
);
|
2026-01-07 22:06:49 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
|
|
|
|
|
if (!skipPersist && createdInstance && typeof window !== "undefined") {
|
2026-01-06 05:21:53 +08:00
|
|
|
|
const data =
|
|
|
|
|
|
createdInstance.getData?.(true) ?? createdInstance.getData?.();
|
|
|
|
|
|
if (data) {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
const safe = canonicalizeMindmapData(data);
|
|
|
|
|
|
initialDataRef.current = safe;
|
2026-01-06 05:21:53 +08:00
|
|
|
|
try {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
2026-01-06 05:21:53 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
2026-01-07 21:21:22 +08:00
|
|
|
|
editor?.updateBlock(block, { props: { ...block.props, data: safe } });
|
2026-01-06 05:21:53 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (docId) {
|
2026-01-08 06:28:14 +08:00
|
|
|
|
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
2026-01-06 05:21:53 +08:00
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
2026-01-07 21:21:22 +08:00
|
|
|
|
body: JSON.stringify({ data: safe }),
|
2026-01-06 05:21:53 +08:00
|
|
|
|
}).catch(() => {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
createdInstance?.destroy();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
|
if (window.__mindmapInstance === createdInstance) {
|
|
|
|
|
|
window.__mindmapInstance = null;
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
try {
|
2026-01-10 10:35:21 +08:00
|
|
|
|
const w = window as unknown as {
|
|
|
|
|
|
__mindmapInstancesById?: Record<string, MindMapInstance>;
|
|
|
|
|
|
__mindmapPersistById?: Record<string, (data: unknown) => void>;
|
|
|
|
|
|
};
|
2026-01-08 06:28:14 +08:00
|
|
|
|
if (w.__mindmapInstancesById && w.__mindmapInstancesById[mindmapId] === createdInstance) {
|
|
|
|
|
|
delete w.__mindmapInstancesById[mindmapId];
|
|
|
|
|
|
}
|
2026-01-10 10:35:21 +08:00
|
|
|
|
if (w.__mindmapPersistById && w.__mindmapPersistById[mindmapId]) {
|
|
|
|
|
|
delete w.__mindmapPersistById[mindmapId];
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
setMindmap(null);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
mindmapRef.current = null;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
mindmapReadyRef.current = false;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-01-06 05:21:53 +08:00
|
|
|
|
}, [block.id, effectiveFullscreen]);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const getInstanceCandidate = () =>
|
|
|
|
|
|
mindmap ??
|
|
|
|
|
|
(typeof window !== "undefined"
|
2026-01-07 07:25:07 +08:00
|
|
|
|
? ((window as unknown as { __mindmapInstance?: MindMapInstance | null })
|
|
|
|
|
|
.__mindmapInstance ?? null)
|
2026-01-05 06:59:54 +08:00
|
|
|
|
: 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;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const last = mm.renderer?.lastActiveNodeList;
|
2026-01-05 06:59:54 +08:00
|
|
|
|
return (list && list[0]) || (last && last[0]) || getRootNode(mm) || null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const handleUndo = () => runWhenReady((mm) => mm.execCommand("BACK"));
|
|
|
|
|
|
const handleRedo = () => runWhenReady((mm) => mm.execCommand("FORWARD"));
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const handlePainter = () => {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const instance = getInstanceCandidate();
|
|
|
|
|
|
if (!instance?.painter) {
|
2025-12-28 10:01:30 +08:00
|
|
|
|
window.alert("格式刷插件未就绪");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-01-05 06:59:54 +08:00
|
|
|
|
instance.painter.startPainter();
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
2026-01-05 06:59:54 +08:00
|
|
|
|
const handleSibling = () =>
|
|
|
|
|
|
execWithReflow((mm) => {
|
|
|
|
|
|
const target = pickActiveOrRoot(mm);
|
|
|
|
|
|
if (!target) return;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const targetUid = getNodeUid(target);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
mm.execCommand?.("SET_NODE_ACTIVE", target, true);
|
|
|
|
|
|
// openEdit=false:保持与 createNewNodeBehavior=activeOnly 的策略一致,避免强制进入编辑模式带来的时序问题
|
|
|
|
|
|
mm.execCommand?.("INSERT_NODE", false, [target]);
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底:某些情况下插入后 activeNodeList 会短暂为空,导致下一步“同级/删除”无目标。
|
|
|
|
|
|
// 这里尝试在下一帧将新插入的节点设为激活(插入同级节点时,新节点位于 target 后)。
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
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;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const parentUid = getNodeUid(parent);
|
2026-01-05 06:59:54 +08:00
|
|
|
|
mm.execCommand?.("SET_NODE_ACTIVE", parent, true);
|
|
|
|
|
|
// openEdit=false:同上,避免插入瞬间强制进入编辑导致异常
|
|
|
|
|
|
mm.execCommand?.("INSERT_CHILD_NODE", false, [parent]);
|
|
|
|
|
|
|
|
|
|
|
|
// 兜底:确保新插入的子节点成为激活节点,保证随后点击“同级节点/删除节点”可用
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
try {
|
2026-01-07 07:25:07 +08:00
|
|
|
|
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[];
|
2026-01-05 06:59:54 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2025-12-28 22:05:37 +08:00
|
|
|
|
const handleDelete = () => {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
runWhenReady((instance) => {
|
|
|
|
|
|
const mm = ensureActiveBefore(instance);
|
|
|
|
|
|
if (!mm) return;
|
|
|
|
|
|
window.setTimeout(() => mm.execCommand("REMOVE_NODE"), 0);
|
|
|
|
|
|
});
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
|
|
|
|
|
const handleSummary = () => {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
runWhenReady((instance) => {
|
|
|
|
|
|
const mm = ensureActiveBefore(instance);
|
|
|
|
|
|
if (mm) {
|
|
|
|
|
|
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
|
|
|
|
|
const handleAssociativeLine = () => {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
runWhenReady((instance) => {
|
|
|
|
|
|
const mm = ensureActiveBefore(instance);
|
|
|
|
|
|
if (mm) {
|
|
|
|
|
|
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
|
|
|
|
|
const handleOuterFrame = () => {
|
2026-01-05 06:59:54 +08:00
|
|
|
|
runWhenReady((instance) => {
|
|
|
|
|
|
const mm = ensureActiveBefore(instance);
|
|
|
|
|
|
if (mm) {
|
|
|
|
|
|
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2025-12-28 22:05:37 +08:00
|
|
|
|
};
|
2025-12-28 10:01:30 +08:00
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
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");
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const [uploadingImage, setUploadingImage] = useState(false);
|
|
|
|
|
|
const [workspaceId, setWorkspaceId] = useState("");
|
2025-12-31 19:26:38 +08:00
|
|
|
|
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 }>({});
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
return urlOrAssetId;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 如果是完整的 URL,直接返回
|
|
|
|
|
|
return urlOrAssetId;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 递归收集思维导图中所有的图片 URL
|
|
|
|
|
|
const collectImageUrls = (data: any): string[] => {
|
|
|
|
|
|
if (!data) return [];
|
|
|
|
|
|
const urls: string[] = [];
|
|
|
|
|
|
const traverse = (node: any) => {
|
|
|
|
|
|
if (!node) return;
|
2026-01-11 12:35:53 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-01-10 23:08:56 +08:00
|
|
|
|
if (Array.isArray(node.children)) {
|
|
|
|
|
|
node.children.forEach(traverse);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
traverse(data);
|
|
|
|
|
|
return urls;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理图片删除(将 asset 移到垃圾桶)
|
2026-01-26 13:19:48 +08:00
|
|
|
|
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);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}
|
2026-01-26 13:19:48 +08:00
|
|
|
|
},
|
|
|
|
|
|
[docId],
|
|
|
|
|
|
);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 处理图片恢复(从垃圾桶恢复)
|
2026-01-26 13:19:48 +08:00
|
|
|
|
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);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}
|
2026-01-26 13:19:48 +08:00
|
|
|
|
},
|
|
|
|
|
|
[docId],
|
|
|
|
|
|
);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
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);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const imgNodeRef = useRef<{ node: MindMapNode | null; imgNode: any }>({ node: null, imgNode: null });
|
2025-12-31 19:26:38 +08:00
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const handleImage = () => {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const first = mindmap?.renderer?.activeNodeList?.[0] as MindMapNode | undefined;
|
2025-12-31 19:26:38 +08:00
|
|
|
|
if (first?.getStyle) {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const placement = first.getStyle("imgPlacement", false) as string;
|
2025-12-31 19:26:38 +08:00
|
|
|
|
if (placement) setImagePosition(placement);
|
|
|
|
|
|
}
|
|
|
|
|
|
setShowImageModal(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 图片工具栏更新位置函数
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
// 预览:监听 mindmap 事件
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!mindmap) return;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const handler = async (node: MindMapNode, e?: Event) => {
|
2025-12-31 19:26:38 +08:00
|
|
|
|
e?.stopPropagation?.();
|
|
|
|
|
|
e?.preventDefault?.();
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const srcRaw =
|
2025-12-31 19:26:38 +08:00
|
|
|
|
node?.nodeData?.data?.image ||
|
|
|
|
|
|
node?.getData?.("image") ||
|
|
|
|
|
|
node?.data?.image;
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const src = typeof srcRaw === "string" ? srcRaw : "";
|
2025-12-31 19:26:38 +08:00
|
|
|
|
if (src) {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 解析图片 URL,如果是 asset_id 格式则获取签名 URL
|
|
|
|
|
|
const resolvedUrl = await resolveImageUrl(src);
|
|
|
|
|
|
setViewerSrc(resolvedUrl);
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const sizeRaw = node?.getData?.("imageSize");
|
|
|
|
|
|
const size =
|
|
|
|
|
|
sizeRaw && typeof sizeRaw === "object"
|
|
|
|
|
|
? (sizeRaw as { width?: unknown; height?: unknown })
|
|
|
|
|
|
: ({} as { width?: unknown; height?: unknown });
|
2025-12-31 19:26:38 +08:00
|
|
|
|
const title = node?.getData?.("imageTitle") || "";
|
|
|
|
|
|
setViewerMeta({
|
|
|
|
|
|
title: typeof title === "string" ? title : "",
|
2026-01-07 07:25:07 +08:00
|
|
|
|
width: Number(size.width) || undefined,
|
|
|
|
|
|
height: Number(size.height) || undefined,
|
2025-12-31 19:26:38 +08:00
|
|
|
|
});
|
2026-01-10 23:08:56 +08:00
|
|
|
|
setViewerZoom(1); // 重置缩放
|
2025-12-31 19:26:38 +08:00
|
|
|
|
setShowImageViewer(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const onActive = (node: MindMapNode) => {
|
|
|
|
|
|
if (node === imgNodeRef.current.node) return;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const list = (mindmap.renderer?.activeNodeList || []) as MindMapNode[];
|
|
|
|
|
|
const has = list.some((n) => !!n?.getData?.("image"));
|
2026-01-10 23:08:56 +08:00
|
|
|
|
if (!has) {
|
|
|
|
|
|
setImgToolbarState((s) => ({ ...s, show: false }));
|
|
|
|
|
|
imgNodeRef.current = { node: null, imgNode: null };
|
|
|
|
|
|
}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
};
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const showToolbarOnClick = (node: MindMapNode, imgNode: any, _evt: Event | undefined) => {
|
|
|
|
|
|
imgNodeRef.current = { node, imgNode };
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const placement = (node?.getStyle?.("imgPlacement") || "top") as "top" | "bottom" | "left" | "right";
|
2025-12-31 19:26:38 +08:00
|
|
|
|
setImagePosition(placement);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
setImgToolbarState((s) => ({
|
|
|
|
|
|
...s,
|
2025-12-31 19:26:38 +08:00
|
|
|
|
show: true,
|
|
|
|
|
|
placement,
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}));
|
|
|
|
|
|
// 使用 requestAnimationFrame 确保 DOM 更新后再定位
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
updateImgToolbarPos();
|
2025-12-31 19:26:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
const hideToolbar = () => {
|
|
|
|
|
|
if (!imgToolbarHover.current) {
|
|
|
|
|
|
setImgToolbarState((s) => ({ ...s, show: false }));
|
2026-01-10 23:08:56 +08:00
|
|
|
|
imgNodeRef.current = { node: null, imgNode: null };
|
2025-12-31 19:26:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
mindmap.on?.("node_img_dblclick", handler);
|
|
|
|
|
|
mindmap.on?.("node_active", onActive);
|
|
|
|
|
|
mindmap.on?.("node_img_click", showToolbarOnClick);
|
|
|
|
|
|
mindmap.on?.("draw_click", hideToolbar);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 添加更多事件监听以正确隐藏/更新工具栏
|
|
|
|
|
|
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);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
return () => {
|
|
|
|
|
|
mindmap.off?.("node_img_dblclick", handler);
|
|
|
|
|
|
mindmap.off?.("node_active", onActive);
|
|
|
|
|
|
mindmap.off?.("node_img_click", showToolbarOnClick);
|
|
|
|
|
|
mindmap.off?.("draw_click", hideToolbar);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
};
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}, [mindmap, updateImgToolbarPos]);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 处理图片删除/恢复(支持撤销)
|
|
|
|
|
|
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);
|
|
|
|
|
|
};
|
2026-01-26 13:19:48 +08:00
|
|
|
|
}, [deleteImageAssets, docId, mindmap, restoreImageAssets]);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 悬浮图片位置工具条(参考 NodeImgPlacementToolbar.vue)
|
2025-12-31 19:26:38 +08:00
|
|
|
|
const renderImgToolbar = () => {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 预览打开时隐藏工具栏
|
|
|
|
|
|
if (!imgToolbarState.show || showImageViewer) return null;
|
2025-12-31 19:26:38 +08:00
|
|
|
|
const { x, y, placement } = imgToolbarState;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
const setPlacement = (p: "top" | "bottom" | "left" | "right") => {
|
|
|
|
|
|
setImagePosition(p);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
applyToActiveNodes(mindmap, (n) =>
|
|
|
|
|
|
mindmap?.execCommand?.("SET_NODE_STYLES", n, { imgPlacement: p }),
|
2025-12-31 19:26:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
setImgToolbarState((s) => ({ ...s, placement: p }));
|
|
|
|
|
|
};
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 位置按钮组件
|
|
|
|
|
|
const PosBtn = (
|
2026-01-02 07:25:50 +08:00
|
|
|
|
p: typeof placement,
|
|
|
|
|
|
Icon: React.ComponentType<{ className?: string }>,
|
|
|
|
|
|
title: string,
|
|
|
|
|
|
) => (
|
2025-12-31 19:26:38 +08:00
|
|
|
|
<button
|
|
|
|
|
|
key={p}
|
|
|
|
|
|
type="button"
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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"
|
2025-12-31 19:26:38 +08:00
|
|
|
|
}`}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
onClick={(e) => {
|
2025-12-31 19:26:38 +08:00
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
setPlacement(p);
|
|
|
|
|
|
}}
|
|
|
|
|
|
title={title}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon className="h-4 w-4" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={imgToolbarRef}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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()}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
>
|
2026-01-10 23:08:56 +08:00
|
|
|
|
{PosBtn("top", ArrowUp, "顶部")}
|
|
|
|
|
|
{PosBtn("bottom", ArrowDown, "底部")}
|
|
|
|
|
|
{PosBtn("left", ArrowLeft, "靠左")}
|
|
|
|
|
|
{PosBtn("right", ArrowRight, "靠右")}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleIcon = () => {
|
2025-12-29 07:25:03 +08:00
|
|
|
|
setActiveSidebar("icons");
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleLink = () => {
|
|
|
|
|
|
const href = createSimplePrompt("请输入超链接");
|
|
|
|
|
|
if (!href) return;
|
|
|
|
|
|
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_HYPERLINK", node, href, href));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNote = () => {
|
2025-12-30 18:39:38 +08:00
|
|
|
|
setShowNoteModal(true);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 = () => {
|
2025-12-29 07:25:03 +08:00
|
|
|
|
setActiveSidebar("formula");
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-30 18:39:38 +08:00
|
|
|
|
const handleNoteConfirm = () => {
|
|
|
|
|
|
const note = noteContent.trim();
|
|
|
|
|
|
setShowNoteModal(false);
|
|
|
|
|
|
if (!note) return;
|
|
|
|
|
|
applyToActiveNodes(mindmap, (node) => mindmap?.execCommand("SET_NODE_NOTE", node, note));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
const handleImageConfirm = async () => {
|
2025-12-31 19:26:38 +08:00
|
|
|
|
const url = imageUrl.trim();
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 获取原始图片尺寸,如果没有则使用默认值
|
2026-01-21 18:21:10 +08:00
|
|
|
|
const width = Number(imageWidth) || 0;
|
|
|
|
|
|
const height = Number(imageHeight) || 0;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
if (!url) {
|
|
|
|
|
|
window.alert("请输入图片链接");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setShowImageModal(false);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 保存旧的 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);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
// 记录映射关系供保存/删除/撤销使用(无论是否签名成功)
|
|
|
|
|
|
const assetId = url.replace(/^asset:/, "").trim();
|
|
|
|
|
|
if (assetId) {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
signedUrlToAssetIdRef.current.set(displayUrl, assetId);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
signedUrlToAssetIdRef.current.set(`asset:${assetId}`, assetId);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
applyToActiveNodes(mindmap, (node) =>
|
|
|
|
|
|
mindmap?.execCommand("SET_NODE_IMAGE", node, {
|
2026-01-10 23:08:56 +08:00
|
|
|
|
url: displayUrl,
|
2025-12-31 19:26:38 +08:00
|
|
|
|
width,
|
|
|
|
|
|
height,
|
|
|
|
|
|
title: imageTitle || "",
|
2026-01-10 23:08:56 +08:00
|
|
|
|
custom: false, // 让 simple-mind-map 自动缩放到合适尺寸
|
2025-12-31 19:26:38 +08:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
applyToActiveNodes(mindmap, (node) =>
|
|
|
|
|
|
mindmap?.execCommand?.("SET_NODE_STYLES", node, {
|
|
|
|
|
|
imgPlacement: imagePosition || "top",
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 等待命令执行后更新 currentImageUrlsRef
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const newData = mindmap?.getData?.();
|
|
|
|
|
|
if (newData) {
|
|
|
|
|
|
const newUrls = collectImageUrls(newData);
|
|
|
|
|
|
currentImageUrlsRef.current = new Set(newUrls);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
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 能力占位:后续接入大模型生成/优化节点内容。");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
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);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
mindmap?.setData(data);
|
|
|
|
|
|
mindmap?.command.clearHistory();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
persistData(data);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return;
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
|
|
|
|
|
// XMind
|
|
|
|
|
|
if (ext === "xmind") {
|
|
|
|
|
|
const xmindParser = await import("simple-mind-map/src/parse/xmind.js");
|
|
|
|
|
|
const blob = new Blob([await file.arrayBuffer()]);
|
2026-01-07 07:25:07 +08:00
|
|
|
|
const data = await xmindParser.default.parseXmindFile(blob, (content) => {
|
|
|
|
|
|
const list = content;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (list.length > 1) {
|
2026-01-07 18:38:56 +08:00
|
|
|
|
window.alert("检测到 XMind 多画布,自动导入第一个画布。");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
return list.length > 0 ? list[0] : content;
|
|
|
|
|
|
});
|
|
|
|
|
|
mindmap?.setData(data);
|
|
|
|
|
|
mindmap?.command.clearHistory();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
persistData(data);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
// MindManager (.mmap)
|
|
|
|
|
|
if (ext === "mmap") {
|
|
|
|
|
|
const { parseMindManagerMmapFile } = await import("./mindmapMindManagerImport");
|
|
|
|
|
|
const data = await parseMindManagerMmapFile(file);
|
|
|
|
|
|
mindmap?.setData(data);
|
|
|
|
|
|
mindmap?.command.clearHistory();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
persistData(data);
|
2026-01-07 18:38:56 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
// 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();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
persistData(data);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-07 18:38:56 +08:00
|
|
|
|
window.alert("暂不支持该文件类型,请选择 .json / .smm / .xmind / .mmap / .md");
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
window.alert("导入失败:文件格式或内容错误");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
reset();
|
|
|
|
|
|
}
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNew = () => {
|
|
|
|
|
|
if (!window.confirm("确定要新建空白导图吗?当前未保存的修改将被覆盖。")) return;
|
|
|
|
|
|
mindmap?.setData(defaultMindmapData);
|
|
|
|
|
|
mindmap?.command.clearHistory();
|
2026-01-09 07:22:27 +08:00
|
|
|
|
persistData(defaultMindmapData);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const handleExport = async (type: string, name = "mindmap") => {
|
2025-12-28 10:01:30 +08:00
|
|
|
|
try {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
await mindmap?.doExport?.export(type, true, name);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
} catch {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
window.alert(`导出 ${type.toUpperCase()} 失败,请稍后再试`);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const handleExportPng = () => handleExport("png");
|
|
|
|
|
|
const handleExportSvg = () => handleExport("svg");
|
|
|
|
|
|
const handleExportPdf = () => handleExport("pdf");
|
|
|
|
|
|
const handleExportMd = () => handleExport("md");
|
|
|
|
|
|
const handleExportTxt = () => handleExport("txt");
|
|
|
|
|
|
const handleExportXmind = () => handleExport("xmind");
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
const handleSaveAs = () => handleExportJson();
|
|
|
|
|
|
|
2026-01-02 07:25:50 +08:00
|
|
|
|
const handleDeleteMindmap = useCallback(async () => {
|
|
|
|
|
|
if (!docId) {
|
2026-01-07 22:06:49 +08:00
|
|
|
|
deletingRef.current = true;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
editor.removeBlocks([block.id]);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-01 08:47:40 +08:00
|
|
|
|
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (!confirmed) return;
|
2026-01-07 22:06:49 +08:00
|
|
|
|
deletingRef.current = true;
|
2026-01-08 06:28:14 +08:00
|
|
|
|
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
|
2026-01-02 07:25:50 +08:00
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
|
const payload = await resp.json().catch(() => ({}));
|
|
|
|
|
|
window.alert(payload?.error ?? "删除思维导图失败");
|
2026-01-07 22:06:49 +08:00
|
|
|
|
deletingRef.current = false;
|
2026-01-02 07:25:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
window.localStorage.removeItem(autosaveKey);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
2026-01-08 06:28:14 +08:00
|
|
|
|
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
|
|
|
|
|
|
// 同时会触发全局删除监听(ASSETS_CHANGED_EVENT)进行块移除,这里做 try/catch 避免重复删除报错
|
|
|
|
|
|
try {
|
|
|
|
|
|
editor.removeBlocks([block.id]);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [autosaveKey, block.id, docId, editor, mindmapId]);
|
2026-01-02 07:25:50 +08:00
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
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,
|
2026-01-02 07:25:50 +08:00
|
|
|
|
onDeleteMindmap: handleDeleteMindmap,
|
2025-12-28 10:01:30 +08:00
|
|
|
|
onExportJson: handleExportJson,
|
|
|
|
|
|
onExportPng: handleExportPng,
|
2026-01-02 07:25:50 +08:00
|
|
|
|
onExportSvg: handleExportSvg,
|
|
|
|
|
|
onExportPdf: handleExportPdf,
|
|
|
|
|
|
onExportMd: handleExportMd,
|
|
|
|
|
|
onExportTxt: handleExportTxt,
|
|
|
|
|
|
onExportXmind: handleExportXmind,
|
2025-12-28 10:01:30 +08:00
|
|
|
|
fileInputRef,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-30 18:39:38 +08:00
|
|
|
|
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">
|
2025-12-31 19:26:38 +08:00
|
|
|
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
|
|
|
|
|
<span className="text-lg font-semibold text-gray-800">备注</span>
|
2025-12-30 18:39:38 +08:00
|
|
|
|
<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;
|
|
|
|
|
|
|
2025-12-31 19:26:38 +08:00
|
|
|
|
const imageViewer = showImageViewer ? (
|
|
|
|
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={() => setShowImageViewer(false)}>
|
2026-01-10 23:08:56 +08:00
|
|
|
|
<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)));
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-12-31 19:26:38 +08:00
|
|
|
|
<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">
|
2026-01-10 23:08:56 +08:00
|
|
|
|
<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>
|
2025-12-31 19:26:38 +08:00
|
|
|
|
</div>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
<img
|
|
|
|
|
|
src={viewerSrc}
|
|
|
|
|
|
alt={viewerMeta.title || "预览"}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
className="max-h-[80vh] max-w-[80vw] rounded-lg shadow-2xl object-contain bg-white transition-transform duration-100"
|
|
|
|
|
|
style={{ transform: `scale(${viewerZoom})` }}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null;
|
|
|
|
|
|
|
2026-01-10 23:08:56 +08:00
|
|
|
|
// 使用 createPortal 将工具栏挂载到 body,确保 position: fixed 相对于视口
|
|
|
|
|
|
const imgToolbar = typeof document !== "undefined" && imgToolbarState.show
|
|
|
|
|
|
? createPortal(renderImgToolbar(), document.body)
|
|
|
|
|
|
: null;
|
2025-12-31 19:26:38 +08:00
|
|
|
|
|
|
|
|
|
|
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">
|
2026-01-10 23:08:56 +08:00
|
|
|
|
{uploadingImage
|
|
|
|
|
|
? "上传中..."
|
|
|
|
|
|
: imageUrl.startsWith("asset:")
|
|
|
|
|
|
? "已选择图片"
|
|
|
|
|
|
: imageUrl.startsWith("data:")
|
|
|
|
|
|
? "已选择本地图片"
|
|
|
|
|
|
: imageUrl
|
|
|
|
|
|
? "已输入图片地址"
|
|
|
|
|
|
: "未选择文件"}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={fileInputForImage}
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept="image/*"
|
|
|
|
|
|
className="hidden"
|
|
|
|
|
|
onChange={async (e) => {
|
|
|
|
|
|
const file = e.target.files?.[0];
|
|
|
|
|
|
if (!file) return;
|
2026-01-10 23:08:56 +08:00
|
|
|
|
if (!workspaceId || !docId) {
|
|
|
|
|
|
window.alert("缺少空间信息,无法上传文件");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setUploadingImage(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
// 先获取原始图片尺寸
|
|
|
|
|
|
const localSize = await getImageSizeSafe(URL.createObjectURL(file));
|
|
|
|
|
|
if (localSize) {
|
|
|
|
|
|
setImageWidth(localSize.width);
|
|
|
|
|
|
setImageHeight(localSize.height);
|
2025-12-31 19:26:38 +08:00
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
|
|
|
|
|
|
const form = new FormData();
|
|
|
|
|
|
form.append("file", file);
|
|
|
|
|
|
form.append("workspaceId", workspaceId);
|
|
|
|
|
|
form.append("documentId", docId);
|
2026-01-11 12:35:53 +08:00
|
|
|
|
if (mindmapId) {
|
|
|
|
|
|
form.append("mindmapId", mindmapId);
|
|
|
|
|
|
}
|
2026-01-10 23:08:56 +08:00
|
|
|
|
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 = "";
|
|
|
|
|
|
}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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;
|
|
|
|
|
|
|
2026-01-06 05:21:53 +08:00
|
|
|
|
if (effectiveFullscreen) {
|
|
|
|
|
|
const fullscreenView = (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={wrapperRef}
|
|
|
|
|
|
data-testid="mindmap-fullscreen"
|
|
|
|
|
|
tabIndex={0}
|
2026-01-11 12:35:53 +08:00
|
|
|
|
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
|
2026-01-06 05:21:53 +08:00
|
|
|
|
>
|
2026-01-11 12:35:53 +08:00
|
|
|
|
<div className="fixed left-1/2 top-2 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
|
2025-12-28 10:01:30 +08:00
|
|
|
|
<MindmapToolbar {...toolbarProps} />
|
|
|
|
|
|
</div>
|
2026-01-11 12:35:53 +08:00
|
|
|
|
<div className="relative h-full w-full pt-0">
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<div
|
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
|
className="h-full w-full"
|
2026-01-06 05:21:53 +08:00
|
|
|
|
data-testid="mindmap-canvas"
|
2026-01-08 06:28:14 +08:00
|
|
|
|
data-mindmap-id={mindmapId}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
contentEditable={false}
|
|
|
|
|
|
/>
|
2026-01-06 05:21:53 +08:00
|
|
|
|
|
|
|
|
|
|
<MindmapSidebarTrigger
|
|
|
|
|
|
activeSidebar={activeSidebar}
|
|
|
|
|
|
onSelect={setActiveSidebar}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
/>
|
2026-01-06 05:21:53 +08:00
|
|
|
|
<MindmapSidebar
|
2026-01-10 10:35:21 +08:00
|
|
|
|
documentId={docId}
|
|
|
|
|
|
mindmapId={mindmapId}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
mindmap={mindmap}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
activeNodes={activeNodes}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
activeTab={activeSidebar}
|
|
|
|
|
|
onClose={() => setActiveSidebar(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MindmapNavigator
|
|
|
|
|
|
mindmap={mindmap}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
fullscreen={effectiveFullscreen}
|
|
|
|
|
|
toggleFullscreen={
|
|
|
|
|
|
fullscreen
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: () => {
|
|
|
|
|
|
if (localFullscreen) exitLocalFullscreen();
|
|
|
|
|
|
else enterLocalFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
|
|
|
|
|
|
miniMapOpen={showMiniMap}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MindmapCount mindmap={mindmap} />
|
|
|
|
|
|
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
2026-01-10 23:08:56 +08:00
|
|
|
|
<MindmapContextMenu mindmap={mindmap} />
|
2025-12-31 19:26:38 +08:00
|
|
|
|
{imgToolbar}
|
2025-12-30 18:39:38 +08:00
|
|
|
|
{noteModal}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
{imageModal}
|
|
|
|
|
|
{imageViewer}
|
2025-12-28 10:01:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-01-06 05:21:53 +08:00
|
|
|
|
|
|
|
|
|
|
if (typeof document === "undefined") return null;
|
|
|
|
|
|
return createPortal(fullscreenView, document.body);
|
2025-12-28 10:01:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-06 05:21:53 +08:00
|
|
|
|
<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"
|
|
|
|
|
|
>
|
2025-12-28 10:01:30 +08:00
|
|
|
|
<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>
|
2026-01-06 05:21:53 +08:00
|
|
|
|
<div
|
|
|
|
|
|
data-testid="mindmap-stage"
|
2026-01-09 07:22:27 +08:00
|
|
|
|
className="relative h-[520px] w-full overflow-hidden bg-white"
|
|
|
|
|
|
onPointerDown={(e) => {
|
|
|
|
|
|
// 阻止事件冒泡到 BlockNote/ProseMirror,避免产生 NodeSelection 导致粘贴替换整块
|
|
|
|
|
|
e.stopPropagation();
|
2026-01-06 05:21:53 +08:00
|
|
|
|
hotkeyScopeRef.current = true;
|
2026-01-09 07:22:27 +08:00
|
|
|
|
lastInteractionAtRef.current = Date.now();
|
2026-01-06 05:21:53 +08:00
|
|
|
|
// 点击后把焦点落到容器上,避免快捷键被编辑器抢走
|
|
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-01-09 07:22:27 +08:00
|
|
|
|
onMouseDown={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
2026-01-06 05:21:53 +08:00
|
|
|
|
hotkeyScopeRef.current = true;
|
2026-01-09 07:22:27 +08:00
|
|
|
|
lastInteractionAtRef.current = Date.now();
|
2026-01-06 05:21:53 +08:00
|
|
|
|
// 兼容:某些环境下 pointer 事件不触发
|
|
|
|
|
|
try {
|
|
|
|
|
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
onDoubleClick={() => {
|
|
|
|
|
|
if (recentNodeDblclickRef.current) return;
|
|
|
|
|
|
enterLocalFullscreen();
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-02 07:25:50 +08:00
|
|
|
|
<div
|
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
|
className="h-full w-full"
|
2026-01-06 05:21:53 +08:00
|
|
|
|
data-testid="mindmap-canvas"
|
2026-01-08 06:28:14 +08:00
|
|
|
|
data-mindmap-id={mindmapId}
|
2026-01-02 07:25:50 +08:00
|
|
|
|
contentEditable={false}
|
|
|
|
|
|
/>
|
2025-12-28 22:05:37 +08:00
|
|
|
|
|
2026-01-07 07:25:07 +08:00
|
|
|
|
<MindmapSidebarTrigger
|
|
|
|
|
|
activeSidebar={activeSidebar}
|
|
|
|
|
|
onSelect={setActiveSidebar}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
/>
|
2026-01-07 07:25:07 +08:00
|
|
|
|
<MindmapSidebar
|
2026-01-10 10:35:21 +08:00
|
|
|
|
documentId={docId}
|
|
|
|
|
|
mindmapId={mindmapId}
|
2026-01-07 07:25:07 +08:00
|
|
|
|
mindmap={mindmap}
|
|
|
|
|
|
activeNodes={activeNodes}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
activeTab={activeSidebar}
|
|
|
|
|
|
onClose={() => setActiveSidebar(null)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MindmapNavigator
|
|
|
|
|
|
mindmap={mindmap}
|
2026-01-06 05:21:53 +08:00
|
|
|
|
fullscreen={effectiveFullscreen}
|
|
|
|
|
|
toggleFullscreen={
|
|
|
|
|
|
fullscreen
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: () => {
|
|
|
|
|
|
if (localFullscreen) exitLocalFullscreen();
|
|
|
|
|
|
else enterLocalFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-28 22:05:37 +08:00
|
|
|
|
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
|
|
|
|
|
|
miniMapOpen={showMiniMap}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MindmapCount mindmap={mindmap} />
|
|
|
|
|
|
<MindmapMiniMap mindmap={mindmap} show={showMiniMap} />
|
2026-01-10 23:08:56 +08:00
|
|
|
|
<MindmapContextMenu mindmap={mindmap} />
|
2025-12-31 19:26:38 +08:00
|
|
|
|
{imgToolbar}
|
2025-12-30 18:39:38 +08:00
|
|
|
|
{noteModal}
|
2025-12-31 19:26:38 +08:00
|
|
|
|
{imageModal}
|
|
|
|
|
|
{imageViewer}
|
2025-12-28 10:01:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export { MindmapBlockView };
|
|
|
|
|
|
|
2026-01-07 07:25:07 +08:00
|
|
|
|
type MindmapBlockViewProps = Parameters<typeof MindmapBlockView>[0];
|
|
|
|
|
|
|
2025-12-28 10:01:30 +08:00
|
|
|
|
export const mindmapBlock = createReactBlockSpec(
|
|
|
|
|
|
{
|
|
|
|
|
|
type: "mindmap",
|
|
|
|
|
|
propSchema: {
|
2026-01-02 07:25:50 +08:00
|
|
|
|
docId: { default: "" },
|
2025-12-28 10:01:30 +08:00
|
|
|
|
data: { default: defaultMindmapData },
|
|
|
|
|
|
},
|
|
|
|
|
|
content: "none",
|
2026-01-07 07:25:07 +08:00
|
|
|
|
} as unknown as BlockConfig<"mindmap", PropSchema, "none">,
|
2025-12-28 10:01:30 +08:00
|
|
|
|
{
|
2026-01-07 07:25:07 +08:00
|
|
|
|
render: (props) => (
|
|
|
|
|
|
<MindmapBlockView {...(props as unknown as MindmapBlockViewProps)} />
|
|
|
|
|
|
),
|
2025-12-28 10:01:30 +08:00
|
|
|
|
},
|
2025-12-28 22:05:37 +08:00
|
|
|
|
);
|