0.1.09思维导图复制粘贴bug
This commit is contained in:
@@ -19,7 +19,6 @@ import type {
|
||||
PropSchema,
|
||||
SpecificBlock,
|
||||
} from "@blocknote/core";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import type { CustomBlockSchema } from "../schema";
|
||||
import { MindmapToolbar } from "./MindmapToolbar";
|
||||
import { MindmapSidebar } from "./MindmapSidebar";
|
||||
@@ -270,7 +269,16 @@ const ensureActiveBefore = (mindmap?: MindMapInstance | null) => {
|
||||
if (fallback) {
|
||||
// 主动维护激活列表,避免首次插入子节点时因无激活节点导致插入位置错误
|
||||
// 先确保根节点居中,避免后续插入节点跑到 (0,0)
|
||||
if (renderer?.setRootNodeCenter) renderer.setRootNodeCenter();
|
||||
// 注意:setRootNodeCenter 内部依赖 renderer.root(而不是 renderTree._node)。
|
||||
// 在“刚初始化/刚插入第二张导图”的窗口期里 renderer.root 可能仍是 null,
|
||||
// 此时调用会触发内部解构异常,导致快捷键/工具栏命令中断。
|
||||
if (renderer?.root && renderer?.setRootNodeCenter) {
|
||||
try {
|
||||
renderer.setRootNodeCenter();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
mm.view?.fit?.();
|
||||
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
||||
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(fallback, true);
|
||||
@@ -317,8 +325,11 @@ const MindmapBlockView = ({
|
||||
const effectiveFullscreen = fullscreen || localFullscreen;
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const hotkeyScopeRef = useRef(false);
|
||||
const lastInteractionAtRef = useRef(0);
|
||||
const skipNextPasteRef = useRef(false);
|
||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||
const recentNodeDblclickRef = useRef(false);
|
||||
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
|
||||
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
|
||||
const deletingRef = useRef(false);
|
||||
|
||||
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||
@@ -399,17 +410,104 @@ const MindmapBlockView = ({
|
||||
const onPointerDownCapture = (e: Event) => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
const target = e.target as Node | null;
|
||||
hotkeyScopeRef.current = !!(target && wrapper.contains(target));
|
||||
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,保证快捷键/粘贴作用域稳定。
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.addEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.addEventListener("mousedown", onPointerDownCapture, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
||||
document.removeEventListener("pointerdown", onPointerDownCapture, true);
|
||||
document.removeEventListener("mousedown", onPointerDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 关键:阻止鼠标事件冒泡到 BlockNote/ProseMirror(它们会在 contenteditable 上处理 mousedown,从而产生 NodeSelection)。
|
||||
// 不能用 React 的 onMouseDown(事件委托在 document,太晚了),必须用原生监听挂在 wrapper 上,确保在 bubble 链路中先于 editor DOM。
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const stopBubble = (e: Event) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
// 节点文本编辑态 / 输入框:允许事件继续冒泡,避免影响输入法/选择/粘贴
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const mm = mindmapRef.current;
|
||||
const editClasses = mm?.editNodeClassList;
|
||||
if (editClasses) {
|
||||
for (const cls of editClasses) {
|
||||
if (!cls) continue;
|
||||
if (target.classList?.contains(cls)) return;
|
||||
const found = target.closest?.(`.${cls}`);
|
||||
if (found) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 只要在思维导图块内点击,就阻止冒泡到 editor,避免“Ctrl+V 替换整块”
|
||||
e.stopPropagation();
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
|
||||
// 同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
wrapper.addEventListener("mousedown", stopBubble);
|
||||
wrapper.addEventListener("pointerdown", stopBubble);
|
||||
return () => {
|
||||
wrapper.removeEventListener("mousedown", stopBubble);
|
||||
wrapper.removeEventListener("pointerdown", stopBubble);
|
||||
};
|
||||
}, [effectiveFullscreen]);
|
||||
|
||||
// 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突
|
||||
useEffect(() => {
|
||||
if (!mindmap) return;
|
||||
@@ -519,7 +617,11 @@ const MindmapBlockView = ({
|
||||
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
|
||||
useLayoutEffect(() => {
|
||||
const onKeyDownCapture = (e: KeyboardEvent) => {
|
||||
if (!hotkeyScopeRef.current) 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;
|
||||
|
||||
@@ -527,19 +629,22 @@ const MindmapBlockView = ({
|
||||
const editClasses = mm.editNodeClassList;
|
||||
const wrapper = wrapperRef.current;
|
||||
const isInWrapper = Boolean(wrapper && target && wrapper.contains(target));
|
||||
if (target && isInWrapper) {
|
||||
const isNodeTextEditing = (() => {
|
||||
if (!target || !isInWrapper) return false;
|
||||
const tag = target.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
// contenteditable 输入中不拦截:避免破坏节点文本编辑/粘贴
|
||||
if (target.isContentEditable) return;
|
||||
const editableAncestor = target.closest?.('[contenteditable="true"]');
|
||||
if (editableAncestor) return;
|
||||
if (editClasses && target.classList) {
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
||||
// 仅在 simple-mind-map 的“节点文本编辑元素”上判定为编辑态;不要用 isContentEditable 泛化判断,
|
||||
// 否则会把库内部的隐藏输入层误判成编辑态,导致 Ctrl+C/Ctrl+V 失效。
|
||||
if (editClasses) {
|
||||
for (const cls of editClasses) {
|
||||
if (cls && target.classList.contains(cls)) return;
|
||||
if (!cls) continue;
|
||||
if (target.classList?.contains(cls)) return true;
|
||||
const found = target.closest?.(`.${cls}`);
|
||||
if (found) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const isMod = e.ctrlKey || e.metaKey;
|
||||
const key = e.key;
|
||||
@@ -566,6 +671,16 @@ const MindmapBlockView = ({
|
||||
if (!node) return;
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
inst.execCommand?.("INSERT_NODE", false, [node]);
|
||||
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
|
||||
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
|
||||
// 切换全屏/刷新后不会丢失。
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -577,18 +692,39 @@ const MindmapBlockView = ({
|
||||
if (!node) return;
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 节点文本编辑中:只接管 Enter/Tab;其余快捷键交给默认输入行为
|
||||
//(例如 Ctrl+C/Ctrl+V 作为文本复制粘贴)。
|
||||
if (isNodeTextEditing) return;
|
||||
|
||||
if (isMod && lower === "c") {
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
const node = pickTargetNode(inst);
|
||||
if (node) {
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
}
|
||||
inst.renderer.copy?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMod && lower === "v") {
|
||||
// 兜底:某些场景下(尤其是嵌入编辑器时)浏览器仍会触发原生 paste 事件。
|
||||
// 这里标记一次,避免我们在 paste capture 里重复执行粘贴逻辑。
|
||||
skipNextPasteRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
skipNextPasteRef.current = false;
|
||||
}, 200);
|
||||
stop();
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
@@ -596,15 +732,232 @@ const MindmapBlockView = ({
|
||||
const copyData = renderer.beingCopyData ?? null;
|
||||
if (copyData) {
|
||||
inst.execCommand?.("PASTE_NODE", copyData);
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
renderer.paste?.();
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDownCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。
|
||||
// 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时,
|
||||
// 强制把粘贴交给 simple-mind-map,并阻止 BlockNote 继续处理。
|
||||
useLayoutEffect(() => {
|
||||
const onPasteCapture = (e: ClipboardEvent) => {
|
||||
const inScope =
|
||||
hotkeyScopeRef.current ||
|
||||
(lastInteractionAtRef.current > 0 &&
|
||||
Date.now() - lastInteractionAtRef.current < 60_000);
|
||||
if (!inScope) return;
|
||||
const mm = mindmapRef.current;
|
||||
if (!mm || !mindmapReadyRef.current) return;
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
const active = (typeof document !== "undefined"
|
||||
? (document.activeElement as HTMLElement | null)
|
||||
: null);
|
||||
|
||||
const isTextEditingNow = (() => {
|
||||
if (!wrapper || !active) return false;
|
||||
if (!wrapper.contains(active)) return false;
|
||||
const tag = active.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
||||
const editClasses = mm.editNodeClassList;
|
||||
if (editClasses) {
|
||||
for (const cls of editClasses) {
|
||||
if (!cls) continue;
|
||||
if (active.classList?.contains(cls)) return true;
|
||||
const found = active.closest?.(`.${cls}`);
|
||||
if (found) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
// 节点文本编辑态:允许默认粘贴(粘贴到节点文本)
|
||||
if (isTextEditingNow) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
|
||||
// 如果刚刚已经在 keydown 里处理过 Ctrl+V,这里只负责阻止 BlockNote 的 paste
|
||||
if (skipNextPasteRef.current) {
|
||||
skipNextPasteRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
|
||||
// 优先交给 simple-mind-map 自己的 clipboard 逻辑:支持外部文本/图片/链接以及内部节点复制格式
|
||||
try {
|
||||
inst.renderer.paste?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 兜底持久化:避免快速切换视图导致“看起来没保存”
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("paste", onPasteCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener("paste", onPasteCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。
|
||||
// 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。
|
||||
useLayoutEffect(() => {
|
||||
const onCopyCapture = (e: ClipboardEvent) => {
|
||||
const inScope =
|
||||
hotkeyScopeRef.current ||
|
||||
(lastInteractionAtRef.current > 0 &&
|
||||
Date.now() - lastInteractionAtRef.current < 60_000);
|
||||
if (!inScope) return;
|
||||
|
||||
const mm = mindmapRef.current;
|
||||
if (!mm || !mindmapReadyRef.current) return;
|
||||
|
||||
const wrapper = wrapperRef.current;
|
||||
const active = (typeof document !== "undefined"
|
||||
? (document.activeElement as HTMLElement | null)
|
||||
: null);
|
||||
const isTextEditingNow = (() => {
|
||||
if (!wrapper || !active) return false;
|
||||
if (!wrapper.contains(active)) return false;
|
||||
const tag = active.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
||||
const editClasses = mm.editNodeClassList;
|
||||
if (editClasses) {
|
||||
for (const cls of editClasses) {
|
||||
if (!cls) continue;
|
||||
if (active.classList?.contains(cls)) return true;
|
||||
const found = active.closest?.(`.${cls}`);
|
||||
if (found) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
if (isTextEditingNow) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
const renderer = inst.renderer;
|
||||
const node =
|
||||
renderer.activeNodeList?.[0] ??
|
||||
renderer.lastActiveNodeList?.[0] ??
|
||||
renderer.root ??
|
||||
renderer.renderTree?._node ??
|
||||
null;
|
||||
if (node) inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
try {
|
||||
inst.renderer.copy?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("copy", onCopyCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener("copy", onCopyCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 额外兜底:部分浏览器/编辑器会优先通过 beforeinput(insertFromPaste) 走粘贴路径,
|
||||
// 即便我们拦截了 paste,也可能仍触发“替换掉思维导图块”的输入。
|
||||
// 这里在 capture 阶段统一阻止 paste 相关的 beforeinput,并把实际粘贴交给 simple-mind-map。
|
||||
useLayoutEffect(() => {
|
||||
const onBeforeInputCapture = (e: InputEvent) => {
|
||||
const inputType = (e as unknown as { inputType?: string }).inputType || "";
|
||||
if (!inputType || !inputType.toLowerCase().includes("paste")) return;
|
||||
|
||||
const inScope =
|
||||
hotkeyScopeRef.current ||
|
||||
(lastInteractionAtRef.current > 0 &&
|
||||
Date.now() - lastInteractionAtRef.current < 60_000);
|
||||
if (!inScope) return;
|
||||
|
||||
const mm = mindmapRef.current;
|
||||
if (!mm || !mindmapReadyRef.current) return;
|
||||
|
||||
// 节点文本编辑态:允许默认粘贴(粘贴到节点文本)
|
||||
const wrapper = wrapperRef.current;
|
||||
const active = (typeof document !== "undefined"
|
||||
? (document.activeElement as HTMLElement | null)
|
||||
: null);
|
||||
const isTextEditingNow = (() => {
|
||||
if (!wrapper || !active) return false;
|
||||
if (!wrapper.contains(active)) return false;
|
||||
const tag = active.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return true;
|
||||
const editClasses = mm.editNodeClassList;
|
||||
if (editClasses) {
|
||||
for (const cls of editClasses) {
|
||||
if (!cls) continue;
|
||||
if (active.classList?.contains(cls)) return true;
|
||||
const found = active.closest?.(`.${cls}`);
|
||||
if (found) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
if (isTextEditingNow) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
try {
|
||||
inst.renderer.paste?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinput", onBeforeInputCapture, true);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinput", onBeforeInputCapture, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -641,10 +994,45 @@ const MindmapBlockView = ({
|
||||
},
|
||||
[autosaveKey, block, docId, editor, mindmapId],
|
||||
);
|
||||
useEffect(() => {
|
||||
persistDataRef.current = persistData;
|
||||
}, [persistData]);
|
||||
|
||||
const debouncedPersist = useDebouncedCallback((data: unknown) => {
|
||||
persistData(data);
|
||||
}, 800);
|
||||
// “有上限的防抖保存”:连续 data_change 只会合并为一次保存,但不会因为持续变化
|
||||
// 而无限期推迟(避免测试/用户快速切换全屏时出现“看起来没保存”)。
|
||||
const persistTimerRef = useRef<number | null>(null);
|
||||
const pendingPersistRef = useRef<unknown | null>(null);
|
||||
const schedulePersist = useCallback(
|
||||
(data: unknown) => {
|
||||
pendingPersistRef.current = data;
|
||||
if (typeof window === "undefined") return;
|
||||
if (persistTimerRef.current != null) return;
|
||||
persistTimerRef.current = window.setTimeout(() => {
|
||||
persistTimerRef.current = null;
|
||||
const pending = pendingPersistRef.current;
|
||||
pendingPersistRef.current = null;
|
||||
if (pending) persistData(pending);
|
||||
}, 800);
|
||||
},
|
||||
[persistData],
|
||||
);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (persistTimerRef.current != null && typeof window !== "undefined") {
|
||||
window.clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
}
|
||||
const pending = pendingPersistRef.current;
|
||||
pendingPersistRef.current = null;
|
||||
if (pending) {
|
||||
try {
|
||||
persistData(pending);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [persistData]);
|
||||
|
||||
const initialSyncDone = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -955,10 +1343,59 @@ const MindmapBlockView = ({
|
||||
hostEl.appendChild(measureDiv);
|
||||
}
|
||||
instance.setMode?.("edit");
|
||||
|
||||
// 兜底:在某些环境/焦点状态下,simple-mind-map 的 data_change 事件可能不会触发,
|
||||
// 或者键盘插件直接执行命令但未走到我们的保存逻辑,最终表现为“节点能插入但不会持久化”。
|
||||
// 这里对 execCommand 做轻量包装:当执行潜在的“结构变更”命令时,主动触发一次防抖保存。
|
||||
const shouldPersistAfterCommand = (cmd: unknown) => {
|
||||
if (typeof cmd !== "string") return false;
|
||||
if (
|
||||
cmd === "INSERT_NODE" ||
|
||||
cmd === "INSERT_CHILD_NODE" ||
|
||||
cmd === "PASTE_NODE" ||
|
||||
cmd === "REMOVE_NODE" ||
|
||||
cmd === "DELETE_NODE"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
cmd.startsWith("INSERT_") ||
|
||||
cmd.startsWith("PASTE_") ||
|
||||
cmd.startsWith("REMOVE_") ||
|
||||
cmd.startsWith("DELETE_")
|
||||
);
|
||||
};
|
||||
const originalExecCommand =
|
||||
typeof instance.execCommand === "function"
|
||||
? instance.execCommand.bind(instance)
|
||||
: null;
|
||||
if (originalExecCommand) {
|
||||
(instance as unknown as { execCommand: (...args: any[]) => any }).execCommand = (
|
||||
cmd: unknown,
|
||||
...args: any[]
|
||||
) => {
|
||||
const ret = originalExecCommand(cmd as any, ...args);
|
||||
if (
|
||||
!applyingRemoteRef.current &&
|
||||
!deletingRef.current &&
|
||||
shouldPersistAfterCommand(cmd)
|
||||
) {
|
||||
hasLocalEditsRef.current = true;
|
||||
try {
|
||||
const snapshot =
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
}
|
||||
const centerAndFit = (retry = 0) => {
|
||||
try {
|
||||
const hostRect = hostEl.getBoundingClientRect?.();
|
||||
if (!hostRect || hostRect.width < 10 || hostRect.height < 10) {
|
||||
if (!hostRect || hostRect.width < 10 || hostRect.height < 10) {
|
||||
if (retry < 5) {
|
||||
window.setTimeout(() => centerAndFit(retry + 1), 80);
|
||||
}
|
||||
@@ -1030,9 +1467,20 @@ const MindmapBlockView = ({
|
||||
instance.on?.("node_active", (_node: unknown, list: unknown[]) => {
|
||||
if (!list || list.length === 0) return;
|
||||
setActiveNodes((list || []) as MindMapNode[]);
|
||||
// 兜底:部分场景下点击节点不会触发外层 pointerdown(被 stopPropagation),
|
||||
// 这里用内部事件标记“当前在思维导图作用域内”,确保 Ctrl+C/Ctrl+V 不会被 BlockNote 抢走。
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
instance.on?.("node_click", (node: unknown) => {
|
||||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||||
// 确保点击即可激活(调用节点自身的 active 逻辑,保持和官方一致)
|
||||
const renderer = instance.renderer;
|
||||
const nodeWithActive = node as unknown as { active?: () => void };
|
||||
if (typeof nodeWithActive.active === "function") {
|
||||
@@ -1049,6 +1497,15 @@ const MindmapBlockView = ({
|
||||
? [node as MindMapNode]
|
||||
: (list as MindMapNode[]),
|
||||
);
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
instance.on?.("painter_start", () => setPainterMode(true));
|
||||
instance.on?.("painter_end", () => setPainterMode(false));
|
||||
@@ -1060,8 +1517,8 @@ const MindmapBlockView = ({
|
||||
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
|
||||
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
|
||||
const snapshot =
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (snapshot) debouncedPersist(snapshot);
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (snapshot) schedulePersist(snapshot);
|
||||
});
|
||||
const renderer = instance.renderer;
|
||||
const rootNode = getRootNode(instance);
|
||||
@@ -1540,7 +1997,7 @@ const MindmapBlockView = ({
|
||||
const data = JSON.parse(text);
|
||||
mindmap?.setData(data);
|
||||
mindmap?.command.clearHistory();
|
||||
debouncedPersist(data);
|
||||
persistData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1557,7 +2014,7 @@ const MindmapBlockView = ({
|
||||
});
|
||||
mindmap?.setData(data);
|
||||
mindmap?.command.clearHistory();
|
||||
debouncedPersist(data);
|
||||
persistData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1567,7 +2024,7 @@ const MindmapBlockView = ({
|
||||
const data = await parseMindManagerMmapFile(file);
|
||||
mindmap?.setData(data);
|
||||
mindmap?.command.clearHistory();
|
||||
debouncedPersist(data);
|
||||
persistData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1583,7 +2040,7 @@ const MindmapBlockView = ({
|
||||
}
|
||||
mindmap?.setData(data);
|
||||
mindmap?.command.clearHistory();
|
||||
debouncedPersist(data);
|
||||
persistData(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1600,7 +2057,7 @@ const MindmapBlockView = ({
|
||||
if (!window.confirm("确定要新建空白导图吗?当前未保存的修改将被覆盖。")) return;
|
||||
mindmap?.setData(defaultMindmapData);
|
||||
mindmap?.command.clearHistory();
|
||||
debouncedPersist(defaultMindmapData);
|
||||
persistData(defaultMindmapData);
|
||||
};
|
||||
|
||||
const handleOpenDirectory = () => {
|
||||
@@ -2011,9 +2468,12 @@ const MindmapBlockView = ({
|
||||
</div>
|
||||
<div
|
||||
data-testid="mindmap-stage"
|
||||
className="relative h-[520px] w-full overflow-hidden bg-white"
|
||||
onPointerDown={() => {
|
||||
className="relative h-[520px] w-full overflow-hidden bg-white"
|
||||
onPointerDown={(e) => {
|
||||
// 阻止事件冒泡到 BlockNote/ProseMirror,避免产生 NodeSelection 导致粘贴替换整块
|
||||
e.stopPropagation();
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
// 点击后把焦点落到容器上,避免快捷键被编辑器抢走
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
@@ -2021,8 +2481,10 @@ const MindmapBlockView = ({
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
hotkeyScopeRef.current = true;
|
||||
lastInteractionAtRef.current = Date.now();
|
||||
// 兼容:某些环境下 pointer 事件不触发
|
||||
try {
|
||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||
|
||||
Reference in New Issue
Block a user