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