思维导图修复
This commit is contained in:
@@ -42,3 +42,5 @@ next-env.d.ts
|
|||||||
|
|
||||||
#test
|
#test
|
||||||
**/test/
|
**/test/
|
||||||
|
pw-tests/**
|
||||||
|
pw-tests/
|
||||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
||||||
import iconConfig from "./mindmapIconConfig";
|
import iconConfig from "./mindmapIconConfig";
|
||||||
import { emitAssetsChanged } from "@/lib/events";
|
import { emitAssetsChanged } from "@/lib/events";
|
||||||
|
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||||
|
|
||||||
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
|
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
|
||||||
// @ts-expect-error 第三方库缺少类型定义
|
// @ts-expect-error 第三方库缺少类型定义
|
||||||
@@ -101,6 +102,7 @@ const patchSvgRbox = async () => {
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const svgModule = await import("@svgdotjs/svg.js");
|
const svgModule = await import("@svgdotjs/svg.js");
|
||||||
const candidates = [(svgModule as any).Element, (svgModule as any).G];
|
const candidates = [(svgModule as any).Element, (svgModule as any).G];
|
||||||
|
let warned = false;
|
||||||
candidates.forEach((Ctor) => {
|
candidates.forEach((Ctor) => {
|
||||||
if (!Ctor?.prototype) return;
|
if (!Ctor?.prototype) return;
|
||||||
if (Ctor.prototype.__wolaiRboxPatched) return;
|
if (Ctor.prototype.__wolaiRboxPatched) return;
|
||||||
@@ -111,17 +113,45 @@ const patchSvgRbox = async () => {
|
|||||||
try {
|
try {
|
||||||
return original.call(this, ref);
|
return original.call(this, ref);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("rbox 失败,返回空边界框以避免崩溃", error);
|
// 退化到 DOM 的 getBoundingClientRect 计算,避免初次渲染时出现 (0,0)
|
||||||
return {
|
const el =
|
||||||
x: 0,
|
// svg.js Element 实例通常持有 node 属性
|
||||||
y: 0,
|
(this as any)?.node ?? (this as any)?.el ?? null;
|
||||||
width: 0,
|
const rect =
|
||||||
height: 0,
|
el && typeof el.getBoundingClientRect === "function"
|
||||||
x2: 0,
|
? el.getBoundingClientRect()
|
||||||
y2: 0,
|
: null;
|
||||||
cx: 0,
|
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 0;
|
||||||
cy: 0,
|
const viewportHeight =
|
||||||
};
|
typeof window !== "undefined" ? window.innerHeight : 0;
|
||||||
|
const defaultWidth = Math.max(120, Math.floor(viewportWidth * 0.2));
|
||||||
|
const defaultHeight = Math.max(60, Math.floor(viewportHeight * 0.1));
|
||||||
|
const fallback = rect && rect.width && rect.height
|
||||||
|
? {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
x2: rect.x + rect.width,
|
||||||
|
y2: rect.y + rect.height,
|
||||||
|
cx: rect.x + rect.width / 2,
|
||||||
|
cy: rect.y + rect.height / 2,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
x: Math.max(0, (viewportWidth - defaultWidth) / 2),
|
||||||
|
y: Math.max(0, (viewportHeight - defaultHeight) / 2),
|
||||||
|
width: defaultWidth,
|
||||||
|
height: defaultHeight,
|
||||||
|
x2: Math.max(0, (viewportWidth + defaultWidth) / 2),
|
||||||
|
y2: Math.max(0, (viewportHeight + defaultHeight) / 2),
|
||||||
|
cx: Math.max(0, viewportWidth / 2),
|
||||||
|
cy: Math.max(0, viewportHeight / 2),
|
||||||
|
};
|
||||||
|
if (!warned) {
|
||||||
|
console.warn("rbox 失败,使用 DOM 边界框降级避免崩溃", error, fallback);
|
||||||
|
warned = true;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ctor.prototype.__wolaiRboxPatched = true;
|
Ctor.prototype.__wolaiRboxPatched = true;
|
||||||
@@ -136,18 +166,22 @@ const applyToActiveNodes = (
|
|||||||
window.alert("思维导图尚未初始化");
|
window.alert("思维导图尚未初始化");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const list = mindmap.renderer?.activeNodeList;
|
const renderer = mindmap.renderer;
|
||||||
|
const list = renderer?.activeNodeList;
|
||||||
if (!list || list.length === 0) {
|
if (!list || list.length === 0) {
|
||||||
// 尝试兜底选中根节点
|
// 优先尝试 lastActiveNodeList(部分操作会短暂清空 activeNodeList)
|
||||||
const root = mindmap.renderer?.root ?? mindmap.renderer?.renderTree?._node;
|
const lastActive = (renderer as any)?.lastActiveNodeList?.[0];
|
||||||
if (root) {
|
const root = renderer?.root ?? renderer?.renderTree?._node;
|
||||||
mindmap.execCommand?.("SET_NODE_ACTIVE", root, true);
|
const fallback = lastActive ?? root;
|
||||||
|
if (fallback) {
|
||||||
|
renderer?.addNodeToActiveList?.(fallback);
|
||||||
|
mindmap.execCommand?.("SET_NODE_ACTIVE", fallback, true);
|
||||||
} else {
|
} else {
|
||||||
window.alert("请选择至少一个节点再执行该操作");
|
window.alert("请选择至少一个节点再执行该操作");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(mindmap.renderer?.activeNodeList ?? []).forEach(handler);
|
(renderer?.activeNodeList ?? []).forEach(handler);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 确保存在激活节点后再执行命令,若无则自动选中根节点
|
// 确保存在激活节点后再执行命令,若无则自动选中根节点
|
||||||
@@ -157,11 +191,21 @@ const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
|
|||||||
window.alert("思维导图尚未初始化");
|
window.alert("思维导图尚未初始化");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const list = mm.renderer?.activeNodeList ?? [];
|
const renderer = mm.renderer;
|
||||||
|
const list = renderer?.activeNodeList ?? [];
|
||||||
if (!list || list.length === 0) {
|
if (!list || list.length === 0) {
|
||||||
const root = mm.renderer?.root ?? mm.renderer?.renderTree?._node;
|
const lastActive = (renderer as any)?.lastActiveNodeList?.[0];
|
||||||
if (root) {
|
const root = renderer?.root ?? renderer?.renderTree?._node;
|
||||||
mm.execCommand?.("SET_NODE_ACTIVE", root, true);
|
const fallback = lastActive ?? root;
|
||||||
|
if (fallback) {
|
||||||
|
// 主动维护激活列表,避免首次插入子节点时因无激活节点导致插入位置错误
|
||||||
|
// 先确保根节点居中,避免后续插入节点跑到 (0,0)
|
||||||
|
renderer?.setRootNodeCenter && renderer.setRootNodeCenter();
|
||||||
|
mm.view?.fit?.();
|
||||||
|
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
|
||||||
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(fallback, true);
|
||||||
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(fallback);
|
||||||
|
mm.execCommand?.("SET_NODE_ACTIVE", fallback, true);
|
||||||
} else {
|
} else {
|
||||||
window.alert("请先选中一个节点");
|
window.alert("请先选中一个节点");
|
||||||
return null;
|
return null;
|
||||||
@@ -179,9 +223,13 @@ const MindmapBlockView = ({
|
|||||||
editor: BlockNoteEditor<CustomBlockSchema>;
|
editor: BlockNoteEditor<CustomBlockSchema>;
|
||||||
fullscreen?: boolean;
|
fullscreen?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const currentDocumentId = useEditorBridgeStore(
|
||||||
|
(state) => state.currentDocumentId,
|
||||||
|
);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
|
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
|
||||||
|
const mindmapReadyRef = useRef(false);
|
||||||
const [canBack, setCanBack] = useState(false);
|
const [canBack, setCanBack] = useState(false);
|
||||||
const [canForward, setCanForward] = useState(false);
|
const [canForward, setCanForward] = useState(false);
|
||||||
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
|
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
|
||||||
@@ -199,10 +247,11 @@ const MindmapBlockView = ({
|
|||||||
const docId = useMemo(
|
const docId = useMemo(
|
||||||
() =>
|
() =>
|
||||||
block.props.docId ||
|
block.props.docId ||
|
||||||
|
currentDocumentId ||
|
||||||
(typeof window !== "undefined"
|
(typeof window !== "undefined"
|
||||||
? window.location.pathname.split("/").pop() ?? ""
|
? window.location.pathname.split("/").pop() ?? ""
|
||||||
: ""),
|
: ""),
|
||||||
[block.props.docId],
|
[block.props.docId, currentDocumentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const autosaveKey = useMemo(
|
const autosaveKey = useMemo(
|
||||||
@@ -262,7 +311,13 @@ const MindmapBlockView = ({
|
|||||||
})
|
})
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
|
emitAssetsChanged(docId, {
|
||||||
|
id: `mindmap-${docId}`,
|
||||||
|
document_id: docId,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_name: "mindmap.json",
|
||||||
|
file_url: `/documents/${docId}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => console.warn("思维导图同步失败", err));
|
.catch((err) => console.warn("思维导图同步失败", err));
|
||||||
@@ -276,21 +331,53 @@ const MindmapBlockView = ({
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
const initialSyncDone = useRef(false);
|
const initialSyncDone = useRef(false);
|
||||||
|
// 若渲染结束后激活列表被清空,定期兜底恢复根节点为选中,避免后续指令找不到父节点
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mindmap) return;
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
const renderer = mindmap.renderer;
|
||||||
|
if (!renderer) return;
|
||||||
|
if (!renderer.activeNodeList || renderer.activeNodeList.length === 0) {
|
||||||
|
const root = getRootNode(mindmap);
|
||||||
|
if (root && renderer.addNodeToActiveList) {
|
||||||
|
renderer.addNodeToActiveList(root, true);
|
||||||
|
renderer.emitNodeActiveEvent?.(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [getRootNode, mindmap]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!docId || !mindmap || initialSyncDone.current) return;
|
if (!docId || !mindmap || initialSyncDone.current) return;
|
||||||
initialSyncDone.current = true;
|
initialSyncDone.current = true;
|
||||||
const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData;
|
const data = mindmap.getData?.() ?? initialDataRef.current ?? defaultMindmapData;
|
||||||
void fetch(`/api/mindmap/${docId}`, {
|
(async () => {
|
||||||
method: "POST",
|
try {
|
||||||
headers: { "Content-Type": "application/json" },
|
const resp = await fetch(`/api/mindmap/${docId}`, {
|
||||||
body: JSON.stringify({ data }),
|
method: "POST",
|
||||||
})
|
headers: { "Content-Type": "application/json" },
|
||||||
.then((resp) => {
|
body: JSON.stringify({ data }),
|
||||||
if (resp.ok) {
|
});
|
||||||
emitAssetsChanged(docId, { id: `mindmap-${docId}`, document_id: docId, asset_type: "mindmap" });
|
if (!resp.ok) {
|
||||||
|
console.warn(
|
||||||
|
"初次创建思维导图文件失败",
|
||||||
|
resp.status,
|
||||||
|
await resp.text().catch(() => ""),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => console.warn("初次创建思维导图文件失败", err));
|
console.warn("初次创建思维导图文件失败", err);
|
||||||
|
} finally {
|
||||||
|
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
|
||||||
|
emitAssetsChanged(docId, {
|
||||||
|
id: `mindmap-${docId}`,
|
||||||
|
document_id: docId,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_name: "mindmap.json",
|
||||||
|
file_url: `/documents/${docId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
}, [docId, mindmap, initialDataRef]);
|
}, [docId, mindmap, initialDataRef]);
|
||||||
|
|
||||||
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
|
// 初始选中根节点,后续不强制抢焦点,允许用户自由选择
|
||||||
@@ -306,10 +393,24 @@ const MindmapBlockView = ({
|
|||||||
}
|
}
|
||||||
}, [mindmap, activeNodes.length]);
|
}, [mindmap, activeNodes.length]);
|
||||||
|
|
||||||
|
// mindmap 实例一旦就绪,立刻向侧边栏广播,确保文件树即时显示 mindmap.json
|
||||||
|
useEffect(() => {
|
||||||
|
if (!docId || !mindmap) return;
|
||||||
|
emitAssetsChanged(docId, {
|
||||||
|
id: `mindmap-${docId}`,
|
||||||
|
document_id: docId,
|
||||||
|
asset_type: "mindmap",
|
||||||
|
file_name: "mindmap.json",
|
||||||
|
file_url: `/documents/${docId}`,
|
||||||
|
});
|
||||||
|
}, [docId, mindmap]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
|
let createdInstance: MindMapInstance | null = null;
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
const hostContainer = containerRef.current;
|
||||||
const [
|
const [
|
||||||
{ default: MindMap },
|
{ default: MindMap },
|
||||||
{ default: Painter },
|
{ default: Painter },
|
||||||
@@ -333,6 +434,7 @@ const MindmapBlockView = ({
|
|||||||
{ default: NodeBase64ImageStorage },
|
{ default: NodeBase64ImageStorage },
|
||||||
{ default: ExportPDF },
|
{ default: ExportPDF },
|
||||||
{ default: ExportXMind },
|
{ default: ExportXMind },
|
||||||
|
MindMapNodeModule,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import("simple-mind-map"),
|
import("simple-mind-map"),
|
||||||
import("simple-mind-map/src/plugins/Painter.js"),
|
import("simple-mind-map/src/plugins/Painter.js"),
|
||||||
@@ -356,8 +458,49 @@ const MindmapBlockView = ({
|
|||||||
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
|
import("simple-mind-map/src/plugins/NodeBase64ImageStorage.js"),
|
||||||
import("simple-mind-map/src/plugins/ExportPDF.js"),
|
import("simple-mind-map/src/plugins/ExportPDF.js"),
|
||||||
import("simple-mind-map/src/plugins/ExportXMind.js"),
|
import("simple-mind-map/src/plugins/ExportXMind.js"),
|
||||||
|
import("simple-mind-map/src/core/render/node/MindMapNode.js"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// React StrictMode(开发环境)会触发 effect 的“挂载-卸载-再挂载”流程:
|
||||||
|
// 若异步加载完成时已经卸载,不应再创建实例,否则可能留下残余 DOM,导致出现多个 root。
|
||||||
|
if (destroyed) return;
|
||||||
|
|
||||||
|
// 兜底修补 simple-mind-map 在创建富文本节点时 host/缓存未就绪导致的空指针
|
||||||
|
const MindMapNodeCtor = (MindMapNodeModule as any)?.default;
|
||||||
|
if (
|
||||||
|
MindMapNodeCtor &&
|
||||||
|
!MindMapNodeCtor.prototype.__wolaiRichtextPatched
|
||||||
|
) {
|
||||||
|
const originalCreate = MindMapNodeCtor.prototype.createRichTextNode;
|
||||||
|
MindMapNodeCtor.prototype.__wolaiRichtextPatched = true;
|
||||||
|
// @ts-expect-error 第三方库原型扩展
|
||||||
|
MindMapNodeCtor.prototype.createRichTextNode = function patched(...args: unknown[]) {
|
||||||
|
// host 兜底:优先使用实例容器,否则退回 body
|
||||||
|
const host: HTMLElement =
|
||||||
|
(this?.mindMap?.el as HTMLElement | null) ?? document.body;
|
||||||
|
if (!this.mindMap) {
|
||||||
|
this.mindMap = { el: host, commonCaches: {} } as any;
|
||||||
|
}
|
||||||
|
if (!this.mindMap.el || typeof (this.mindMap.el as any).appendChild !== "function") {
|
||||||
|
this.mindMap.el = host;
|
||||||
|
}
|
||||||
|
if (!this.mindMap.commonCaches) {
|
||||||
|
this.mindMap.commonCaches = {};
|
||||||
|
}
|
||||||
|
if (!this.mindMap.commonCaches.measureRichtextNodeTextSizeEl) {
|
||||||
|
const measureDiv = document.createElement("div");
|
||||||
|
measureDiv.style.position = "fixed";
|
||||||
|
measureDiv.style.left = "-999999px";
|
||||||
|
(this.mindMap.el ?? document.body).appendChild(measureDiv);
|
||||||
|
this.mindMap.commonCaches.measureRichtextNodeTextSizeEl = measureDiv;
|
||||||
|
}
|
||||||
|
if (typeof originalCreate === "function") {
|
||||||
|
return originalCreate.apply(this, args);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const plugins = [
|
const plugins = [
|
||||||
{ name: "Painter", plugin: Painter },
|
{ name: "Painter", plugin: Painter },
|
||||||
{ name: "AssociativeLine", plugin: AssociativeLine },
|
{ name: "AssociativeLine", plugin: AssociativeLine },
|
||||||
@@ -403,7 +546,15 @@ const MindmapBlockView = ({
|
|||||||
|
|
||||||
await patchSvgRbox();
|
await patchSvgRbox();
|
||||||
|
|
||||||
const hostEl = containerRef.current ?? document.body;
|
if (destroyed) return;
|
||||||
|
|
||||||
|
const hostEl = hostContainer;
|
||||||
|
// 防止同一容器残留旧实例的 svg/dom(StrictMode 或异常 destroy 场景)
|
||||||
|
try {
|
||||||
|
hostEl.replaceChildren();
|
||||||
|
} catch {
|
||||||
|
hostEl.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
const instance = new MindMap({
|
const instance = new MindMap({
|
||||||
el: hostEl,
|
el: hostEl,
|
||||||
@@ -415,12 +566,18 @@ const MindmapBlockView = ({
|
|||||||
enableCtrlKeyNodeSelection: true,
|
enableCtrlKeyNodeSelection: true,
|
||||||
fit: true,
|
fit: true,
|
||||||
useLeftKeySelectionRightKeyDrag: true,
|
useLeftKeySelectionRightKeyDrag: true,
|
||||||
|
// 新建节点默认激活,编辑由我们手动触发,避免初次插入时定位到 (0,0)
|
||||||
|
createNewNodeBehavior: "activeOnly",
|
||||||
// 传入扩展图标表,和官方一致
|
// 传入扩展图标表,和官方一致
|
||||||
iconList: mergerIconList([
|
iconList: mergerIconList([
|
||||||
...nodeIconList,
|
...nodeIconList,
|
||||||
...(iconConfig as unknown[]),
|
...(iconConfig as unknown[]),
|
||||||
]),
|
]),
|
||||||
}) as MindMapInstance;
|
}) as MindMapInstance;
|
||||||
|
createdInstance = instance;
|
||||||
|
// 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用
|
||||||
|
//(render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。
|
||||||
|
mindmapReadyRef.current = true;
|
||||||
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
|
// 确保测量节点的缓存容器始终挂在有效 DOM 上,避免 appendChild 空指针
|
||||||
if (!(instance as any).commonCaches) {
|
if (!(instance as any).commonCaches) {
|
||||||
(instance as any).commonCaches = {};
|
(instance as any).commonCaches = {};
|
||||||
@@ -433,6 +590,38 @@ const MindmapBlockView = ({
|
|||||||
(hostEl ?? document.body).appendChild(measureDiv);
|
(hostEl ?? document.body).appendChild(measureDiv);
|
||||||
}
|
}
|
||||||
instance.setMode?.("edit");
|
instance.setMode?.("edit");
|
||||||
|
const centerAndFit = (retry = 0) => {
|
||||||
|
try {
|
||||||
|
const hostRect = hostEl.getBoundingClientRect?.();
|
||||||
|
if (!hostRect || hostRect.width < 10 || hostRect.height < 10) {
|
||||||
|
if (retry < 5) {
|
||||||
|
window.setTimeout(() => centerAndFit(retry + 1), 80);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 只要容器尺寸已可用,就允许工具栏命令执行;render_end 可能早于监听注册触发
|
||||||
|
//(尤其在首次构造时同步渲染),因此不要完全依赖 render_end 来标记就绪。
|
||||||
|
mindmapReadyRef.current = true;
|
||||||
|
const renderer = instance.renderer;
|
||||||
|
const rootNode =
|
||||||
|
// @ts-expect-error 第三方库字段
|
||||||
|
renderer?.root ??
|
||||||
|
// @ts-expect-error 第三方库字段
|
||||||
|
renderer?.renderTree?._node;
|
||||||
|
if (rootNode && renderer?.setRootNodeCenter) {
|
||||||
|
renderer.setRootNodeCenter();
|
||||||
|
}
|
||||||
|
instance.view?.fit?.();
|
||||||
|
} catch (error) {
|
||||||
|
if (retry < 3) {
|
||||||
|
window.setTimeout(() => centerAndFit(retry + 1), 50);
|
||||||
|
} else {
|
||||||
|
console.warn("思维导图初始居中失败,已跳过", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(() => centerAndFit());
|
||||||
|
window.setTimeout(() => centerAndFit(), 80);
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
// 便于开发阶段在控制台直接调试实例
|
// 便于开发阶段在控制台直接调试实例
|
||||||
@@ -446,6 +635,34 @@ const MindmapBlockView = ({
|
|||||||
setCanBack(index > 0);
|
setCanBack(index > 0);
|
||||||
setCanForward(index < len - 1);
|
setCanForward(index < len - 1);
|
||||||
});
|
});
|
||||||
|
// 渲染完成后再二次居中,并确保根节点被标记为激活
|
||||||
|
const handleRenderEnd = () => {
|
||||||
|
const renderer = instance.renderer;
|
||||||
|
const root = getRootNode(instance);
|
||||||
|
if (root) {
|
||||||
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
||||||
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true);
|
||||||
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
|
||||||
|
}
|
||||||
|
mindmapReadyRef.current = true;
|
||||||
|
console.log("[mindmap] render_end activeLen", renderer?.activeNodeList?.length ?? 0);
|
||||||
|
centerAndFit();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注意:部分版本的 simple-mind-map 中 `once` 回调的触发顺序可能早于 `on`,
|
||||||
|
// 若在 once 里 off 监听,可能导致 handleRenderEnd 永远不执行,进而使工具栏按钮不可用。
|
||||||
|
// 这里统一用 on + 手动自销毁,确保首个 render_end 一定会执行 handleRenderEnd。
|
||||||
|
let renderHandled = false;
|
||||||
|
const onRenderEndOnce = () => {
|
||||||
|
if (renderHandled) return;
|
||||||
|
renderHandled = true;
|
||||||
|
try {
|
||||||
|
handleRenderEnd();
|
||||||
|
} finally {
|
||||||
|
instance.off?.("render_end", onRenderEndOnce);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
instance.on?.("render_end", onRenderEndOnce);
|
||||||
instance.on?.("node_active", (_node: unknown, list: unknown[]) => {
|
instance.on?.("node_active", (_node: unknown, list: unknown[]) => {
|
||||||
if (!list || list.length === 0) return;
|
if (!list || list.length === 0) return;
|
||||||
setActiveNodes(list || []);
|
setActiveNodes(list || []);
|
||||||
@@ -453,19 +670,20 @@ const MindmapBlockView = ({
|
|||||||
instance.on?.("node_click", (node: unknown) => {
|
instance.on?.("node_click", (node: unknown) => {
|
||||||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||||||
// @ts-expect-error 第三方库节点对象
|
// @ts-expect-error 第三方库节点对象
|
||||||
if (typeof node?.active === "function") {
|
const renderer = instance.renderer;
|
||||||
|
if (typeof (node as any)?.active === "function") {
|
||||||
// @ts-expect-error simple-mind-map 节点对象缺少类型
|
// @ts-expect-error simple-mind-map 节点对象缺少类型
|
||||||
node.active();
|
node.active();
|
||||||
} else {
|
} else {
|
||||||
// 兜底:手动维护激活列表
|
// 兜底:手动维护激活列表
|
||||||
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
||||||
instance.renderer?.clearActiveNodeList?.();
|
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
|
||||||
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
||||||
instance.renderer?.addNodeToActiveList?.(node, true);
|
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(node, true);
|
||||||
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
// @ts-expect-error simple-mind-map 渲染器缺少类型
|
||||||
instance.renderer?.emitNodeActiveEvent?.(node);
|
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(node);
|
||||||
}
|
}
|
||||||
const list = instance.renderer?.activeNodeList ?? [];
|
const list = renderer?.activeNodeList ?? [];
|
||||||
setActiveNodes(list.length === 0 && node ? [node] : (list as unknown[]));
|
setActiveNodes(list.length === 0 && node ? [node] : (list as unknown[]));
|
||||||
});
|
});
|
||||||
instance.on?.("painter_start", () => setPainterMode(true));
|
instance.on?.("painter_start", () => setPainterMode(true));
|
||||||
@@ -473,10 +691,26 @@ const MindmapBlockView = ({
|
|||||||
instance.on?.("data_change", (data: unknown) => {
|
instance.on?.("data_change", (data: unknown) => {
|
||||||
debouncedPersist(data);
|
debouncedPersist(data);
|
||||||
});
|
});
|
||||||
|
const renderer = instance.renderer;
|
||||||
const rootNode = getRootNode(instance);
|
const rootNode = getRootNode(instance);
|
||||||
if (rootNode) {
|
if (rootNode) {
|
||||||
|
// 初始化时主动标记根节点为选中,确保后续插入子节点有合法的父节点
|
||||||
|
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
|
||||||
|
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(rootNode, true);
|
||||||
|
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(rootNode);
|
||||||
|
console.log("[mindmap] init select root", instance.renderer?.activeNodeList?.length ?? 0);
|
||||||
setActiveNodes([rootNode]);
|
setActiveNodes([rootNode]);
|
||||||
}
|
}
|
||||||
|
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const root = getRootNode(instance);
|
||||||
|
if (!root) return;
|
||||||
|
renderer?.clearActiveNodeList && renderer.clearActiveNodeList();
|
||||||
|
renderer?.addNodeToActiveList && renderer.addNodeToActiveList(root, true);
|
||||||
|
renderer?.emitNodeActiveEvent && renderer.emitNodeActiveEvent(root);
|
||||||
|
console.log("[mindmap] delayed select root", instance.renderer?.activeNodeList?.length ?? 0);
|
||||||
|
setActiveNodes([root]);
|
||||||
|
}, 120);
|
||||||
|
|
||||||
if (destroyed) {
|
if (destroyed) {
|
||||||
instance.destroy();
|
instance.destroy();
|
||||||
@@ -485,52 +719,193 @@ const MindmapBlockView = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
setMindmap((prev) => {
|
try {
|
||||||
prev?.destroy();
|
createdInstance?.destroy();
|
||||||
return null;
|
} catch {
|
||||||
});
|
// ignore
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// @ts-expect-error 调试用全局变量
|
||||||
|
if (window.__mindmapInstance === createdInstance) {
|
||||||
|
// @ts-expect-error 调试用全局变量
|
||||||
|
window.__mindmapInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMindmap(null);
|
||||||
|
mindmapReadyRef.current = false;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [block.id]);
|
}, [block.id]);
|
||||||
|
|
||||||
const handleUndo = () => mindmap?.execCommand("BACK");
|
const getInstanceCandidate = () =>
|
||||||
const handleRedo = () => mindmap?.execCommand("FORWARD");
|
mindmap ??
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? // @ts-expect-error 调试用全局变量
|
||||||
|
(window.__mindmapInstance as MindMapInstance | undefined | null)
|
||||||
|
: null);
|
||||||
|
|
||||||
|
const runWhenReady = (
|
||||||
|
fn: (mm: MindMapInstance) => void,
|
||||||
|
retry = 0,
|
||||||
|
) => {
|
||||||
|
const instance = getInstanceCandidate();
|
||||||
|
if (mindmapReadyRef.current && instance) {
|
||||||
|
fn(instance);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (retry < 60) {
|
||||||
|
window.setTimeout(() => runWhenReady(fn, retry + 1), 50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const recenterIfNeeded = (mm?: MindMapInstance | null) => {
|
||||||
|
try {
|
||||||
|
const instance = mm ?? getInstanceCandidate();
|
||||||
|
const renderer = instance?.renderer;
|
||||||
|
const view = instance?.view;
|
||||||
|
renderer?.setRootNodeCenter && renderer.setRootNodeCenter();
|
||||||
|
view?.fit && view.fit();
|
||||||
|
} catch {
|
||||||
|
/* 空 */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const execWithReflow = (runner: (mm: MindMapInstance) => void) => {
|
||||||
|
const attempt = (retry = 0) => {
|
||||||
|
const instance = getInstanceCandidate();
|
||||||
|
if (!instance || !mindmapReadyRef.current) {
|
||||||
|
if (retry < 60) window.setTimeout(() => attempt(retry + 1), 80);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mm = ensureActiveBefore(instance);
|
||||||
|
if (!mm) return;
|
||||||
|
try {
|
||||||
|
const renderer = mm.renderer as any;
|
||||||
|
if (renderer?.refresh) renderer.refresh();
|
||||||
|
if (renderer?.reRender) renderer.reRender();
|
||||||
|
recenterIfNeeded(mm);
|
||||||
|
} catch {
|
||||||
|
/* 空 */
|
||||||
|
}
|
||||||
|
runner(mm);
|
||||||
|
};
|
||||||
|
attempt();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickActiveOrRoot = (mm: MindMapInstance) => {
|
||||||
|
const list = mm.renderer?.activeNodeList;
|
||||||
|
const last = (mm.renderer as any)?.lastActiveNodeList;
|
||||||
|
// @ts-expect-error 第三方库类型缺失
|
||||||
|
return (list && list[0]) || (last && last[0]) || getRootNode(mm) || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUndo = () => runWhenReady((mm) => mm.execCommand("BACK"));
|
||||||
|
const handleRedo = () => runWhenReady((mm) => mm.execCommand("FORWARD"));
|
||||||
const handlePainter = () => {
|
const handlePainter = () => {
|
||||||
if (!mindmap?.painter) {
|
const instance = getInstanceCandidate();
|
||||||
|
if (!instance?.painter) {
|
||||||
window.alert("格式刷插件未就绪");
|
window.alert("格式刷插件未就绪");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mindmap.painter.startPainter();
|
instance.painter.startPainter();
|
||||||
};
|
|
||||||
const handleSibling = () => {
|
|
||||||
if (!mindmap) return;
|
|
||||||
window.setTimeout(() => mindmap.execCommand("INSERT_NODE"), 0);
|
|
||||||
};
|
|
||||||
const handleChild = () => {
|
|
||||||
if (!mindmap) return;
|
|
||||||
window.setTimeout(() => mindmap.execCommand("INSERT_CHILD_NODE"), 0);
|
|
||||||
};
|
};
|
||||||
|
const handleSibling = () =>
|
||||||
|
execWithReflow((mm) => {
|
||||||
|
const target = pickActiveOrRoot(mm);
|
||||||
|
if (!target) return;
|
||||||
|
const targetUid =
|
||||||
|
// @ts-expect-error simple-mind-map 节点缺少类型
|
||||||
|
(typeof (target as any)?.getData === "function"
|
||||||
|
? (target as any).getData("uid")
|
||||||
|
: (target as any)?.uid) ?? null;
|
||||||
|
mm.execCommand?.("SET_NODE_ACTIVE", target, true);
|
||||||
|
// openEdit=false:保持与 createNewNodeBehavior=activeOnly 的策略一致,避免强制进入编辑模式带来的时序问题
|
||||||
|
mm.execCommand?.("INSERT_NODE", false, [target]);
|
||||||
|
|
||||||
|
// 兜底:某些情况下插入后 activeNodeList 会短暂为空,导致下一步“同级/删除”无目标。
|
||||||
|
// 这里尝试在下一帧将新插入的节点设为激活(插入同级节点时,新节点位于 target 后)。
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const renderer = mm.renderer as any;
|
||||||
|
if ((renderer?.activeNodeList?.length ?? 0) > 0) return;
|
||||||
|
if (!targetUid || typeof renderer?.findNodeByUid !== "function") return;
|
||||||
|
const currentTarget = renderer.findNodeByUid(targetUid);
|
||||||
|
const parent = currentTarget?.parent;
|
||||||
|
const siblings: any[] = parent?.children ?? [];
|
||||||
|
const idx = siblings.indexOf(currentTarget);
|
||||||
|
const inserted = idx >= 0 ? siblings[idx + 1] : null;
|
||||||
|
if (!inserted) return;
|
||||||
|
renderer?.clearActiveNodeList?.();
|
||||||
|
renderer?.addNodeToActiveList?.(inserted, true);
|
||||||
|
renderer.lastActiveNodeList = [inserted];
|
||||||
|
renderer?.emitNodeActiveEvent?.(inserted);
|
||||||
|
mm.execCommand?.("SET_NODE_ACTIVE", inserted, true);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
const handleChild = () =>
|
||||||
|
execWithReflow((mm) => {
|
||||||
|
const parent = pickActiveOrRoot(mm);
|
||||||
|
if (!parent) return;
|
||||||
|
const parentUid =
|
||||||
|
// @ts-expect-error simple-mind-map 节点缺少类型
|
||||||
|
(typeof (parent as any)?.getData === "function"
|
||||||
|
? (parent as any).getData("uid")
|
||||||
|
: (parent as any)?.uid) ?? null;
|
||||||
|
mm.execCommand?.("SET_NODE_ACTIVE", parent, true);
|
||||||
|
// openEdit=false:同上,避免插入瞬间强制进入编辑导致异常
|
||||||
|
mm.execCommand?.("INSERT_CHILD_NODE", false, [parent]);
|
||||||
|
|
||||||
|
// 兜底:确保新插入的子节点成为激活节点,保证随后点击“同级节点/删除节点”可用
|
||||||
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const renderer = mm.renderer as any;
|
||||||
|
if ((renderer?.activeNodeList?.length ?? 0) > 0) return;
|
||||||
|
if (!parentUid || typeof renderer?.findNodeByUid !== "function") return;
|
||||||
|
const currentParent = renderer.findNodeByUid(parentUid);
|
||||||
|
const children: any[] = currentParent?.children ?? [];
|
||||||
|
const inserted = children.length > 0 ? children[children.length - 1] : null;
|
||||||
|
if (!inserted) return;
|
||||||
|
renderer?.clearActiveNodeList?.();
|
||||||
|
renderer?.addNodeToActiveList?.(inserted, true);
|
||||||
|
renderer.lastActiveNodeList = [inserted];
|
||||||
|
renderer?.emitNodeActiveEvent?.(inserted);
|
||||||
|
mm.execCommand?.("SET_NODE_ACTIVE", inserted, true);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
if (!mindmap) return;
|
runWhenReady((instance) => {
|
||||||
window.setTimeout(() => mindmap.execCommand("REMOVE_NODE"), 0);
|
const mm = ensureActiveBefore(instance);
|
||||||
|
if (!mm) return;
|
||||||
|
window.setTimeout(() => mm.execCommand("REMOVE_NODE"), 0);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleSummary = () => {
|
const handleSummary = () => {
|
||||||
const mm = ensureActiveBefore(mindmap);
|
runWhenReady((instance) => {
|
||||||
if (mm) {
|
const mm = ensureActiveBefore(instance);
|
||||||
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
|
if (mm) {
|
||||||
}
|
window.setTimeout(() => mm.execCommand("ADD_GENERALIZATION"), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleAssociativeLine = () => {
|
const handleAssociativeLine = () => {
|
||||||
const mm = ensureActiveBefore(mindmap);
|
runWhenReady((instance) => {
|
||||||
if (mm) {
|
const mm = ensureActiveBefore(instance);
|
||||||
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
|
if (mm) {
|
||||||
}
|
window.setTimeout(() => mm.execCommand("ADD_ASSOCIATIVE_LINE"), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleOuterFrame = () => {
|
const handleOuterFrame = () => {
|
||||||
const mm = ensureActiveBefore(mindmap);
|
runWhenReady((instance) => {
|
||||||
if (mm) {
|
const mm = ensureActiveBefore(instance);
|
||||||
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
|
if (mm) {
|
||||||
}
|
window.setTimeout(() => mm.execCommand("ADD_OUTER_FRAME"), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const [showImageModal, setShowImageModal] = useState(false);
|
const [showImageModal, setShowImageModal] = useState(false);
|
||||||
@@ -865,7 +1240,7 @@ const MindmapBlockView = ({
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
emitAssetsChanged(docId);
|
emitAssetsChanged(docId, undefined, undefined, true);
|
||||||
editor.removeBlocks([block.id]);
|
editor.removeBlocks([block.id]);
|
||||||
}, [autosaveKey, block.id, docId, editor]);
|
}, [autosaveKey, block.id, docId, editor]);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import {
|
|||||||
type NodeToolbarKey,
|
type NodeToolbarKey,
|
||||||
} from "./mindmapToolbarConfig";
|
} from "./mindmapToolbarConfig";
|
||||||
|
|
||||||
|
const stopEditorEvent = (e: React.SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
type ToolbarProps = {
|
type ToolbarProps = {
|
||||||
canBack: boolean;
|
canBack: boolean;
|
||||||
canForward: boolean;
|
canForward: boolean;
|
||||||
@@ -62,7 +67,13 @@ const ToolbarButton = ({
|
|||||||
}) => (
|
}) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
contentEditable={false}
|
||||||
|
onPointerDown={stopEditorEvent}
|
||||||
|
onMouseDown={stopEditorEvent}
|
||||||
|
onClick={(e) => {
|
||||||
|
stopEditorEvent(e);
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
|
className={`flex flex-col items-center justify-center gap-1 px-2 py-1 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
|
active ? "bg-blue-50 text-blue-600" : "text-gray-600"
|
||||||
@@ -158,7 +169,12 @@ export const MindmapToolbar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
<div
|
||||||
|
className="flex w-full items-center justify-between gap-4 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
contentEditable={false}
|
||||||
|
onPointerDownCapture={(e) => e.stopPropagation()}
|
||||||
|
onMouseDownCapture={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{/* Left Section: Edit & Node Operations */}
|
{/* Left Section: Edit & Node Operations */}
|
||||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-gray-100 bg-white px-2 py-1 shadow-sm">
|
||||||
{nodeToolbarOrder.map((key) => {
|
{nodeToolbarOrder.map((key) => {
|
||||||
@@ -213,7 +229,11 @@ export const MindmapToolbar = ({
|
|||||||
key={item.label}
|
key={item.label}
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||||
onClick={() => {
|
contentEditable={false}
|
||||||
|
onPointerDown={stopEditorEvent}
|
||||||
|
onMouseDown={stopEditorEvent}
|
||||||
|
onClick={(e) => {
|
||||||
|
stopEditorEvent(e);
|
||||||
setShowExport(false);
|
setShowExport(false);
|
||||||
item.onClick();
|
item.onClick();
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user