From 63050f3927f28e6201ff907c324664316ca2f1a7 Mon Sep 17 00:00:00 2001 From: liaibo Date: Sat, 10 Jan 2026 12:01:38 +0800 Subject: [PATCH] =?UTF-8?q?0.1.12=E5=85=A8=E5=B1=8F=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/editor/blocks/MindmapBlock.tsx | 196 ++++++++++++++++-- 1 file changed, 183 insertions(+), 13 deletions(-) diff --git a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx index 33d7ff8e..1be430ae 100644 --- a/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx +++ b/wolai-frontend/src/components/editor/blocks/MindmapBlock.tsx @@ -332,6 +332,11 @@ const MindmapBlockView = ({ const persistDataRef = useRef<((data: unknown) => void) | null>(null); const recentNodeDblclickRef = useRef(false); const deletingRef = useRef(false); + const textEditOpenRef = useRef(false); + const suppressTextEditRefocusUntilRef = useRef(0); + const lastTextEditRefocusAtRef = useRef(0); + const pendingInitActivateRootRef = useRef(false); + const pendingRenderEndActivateRootRef = useRef(false); const getRootNode = useCallback((mm?: MindMapInstance | null) => { const instance = mm ?? mindmap; @@ -464,7 +469,6 @@ const MindmapBlockView = ({ useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) return; - if (effectiveFullscreen) return; const stopBubble = (e: Event) => { const target = e.target as HTMLElement | null; @@ -488,19 +492,22 @@ const MindmapBlockView = ({ hotkeyScopeRef.current = true; lastInteractionAtRef.current = Date.now(); - // 同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定 - try { - wrapperRef.current?.focus?.({ preventScroll: true }); - } catch { - // ignore - } - window.setTimeout(() => { + // 内嵌视图:同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定。 + // 全屏(Portal)视图:不能强制 focus wrapper,否则会打断节点双击编辑输入框的焦点。 + if (!effectiveFullscreen) { try { wrapperRef.current?.focus?.({ preventScroll: true }); } catch { // ignore } - }, 0); + window.setTimeout(() => { + try { + wrapperRef.current?.focus?.({ preventScroll: true }); + } catch { + // ignore + } + }, 0); + } }; wrapper.addEventListener("mousedown", stopBubble); @@ -526,6 +533,137 @@ const MindmapBlockView = ({ }; }, [mindmap]); + // 记录“是否处于节点文本编辑态”,并在全屏时做一次“焦点保护”:避免外部组件/浏览器机制 + // 在延迟(例如 1s)后抢走焦点,导致编辑框/光标消失(用户反馈)。 + useEffect(() => { + if (!mindmap) return; + const onShow = () => { + textEditOpenRef.current = true; + // 刚进入编辑态时允许立即 refocus(不抑制) + suppressTextEditRefocusUntilRef.current = 0; + // 兜底:部分环境下焦点会在进入编辑态后稍后被抢走,这里做一次 1s 的检查 + window.setTimeout(() => { + if (!effectiveFullscreen) return; + if (!textEditOpenRef.current) return; + const wrap = document.querySelector( + ".smm-node-edit-wrap, .smm-richtext-node-edit-wrap", + ) as HTMLElement | null; + if (!wrap) return; + const style = window.getComputedStyle(wrap); + if (style.display === "none" || style.visibility === "hidden") return; + const active = document.activeElement as HTMLElement | null; + if (active && wrap.contains(active)) return; + const editorEl = + (wrap.querySelector(".ql-editor") as HTMLElement | null) ?? wrap; + try { + editorEl.focus({ preventScroll: true }); + } catch { + // ignore + } + }, 1100); + }; + const onHide = () => { + textEditOpenRef.current = false; + // 用户反馈:全屏下双击节点进入编辑后,约 1s 后光标/输入框会消失。 + // 根因通常是初始化兜底激活(或 render_end 回调)在编辑中途清空 active 列表/重新激活根节点,导致编辑被打断。 + // 这里把这类“激活根节点”的动作延后到结束编辑后再执行。 + if (pendingInitActivateRootRef.current || pendingRenderEndActivateRootRef.current) { + pendingInitActivateRootRef.current = false; + pendingRenderEndActivateRootRef.current = false; + window.setTimeout(() => { + const mm = mindmapRef.current; + const renderer = mm?.renderer; + const root = + renderer?.root ?? renderer?.renderTree?._node ?? null; + if (!mm || !renderer || !root) return; + try { + renderer?.clearActiveNodeList?.(); + renderer?.addNodeToActiveList?.(root, true); + renderer?.emitNodeActiveEvent?.(root); + setActiveNodes([root as MindMapNode]); + } catch { + // ignore + } + }, 0); + } + }; + mindmap.on?.("before_show_text_edit", onShow); + mindmap.on?.("hide_text_edit", onHide); + return () => { + mindmap.off?.("before_show_text_edit", onShow); + mindmap.off?.("hide_text_edit", onHide); + }; + }, [mindmap, effectiveFullscreen]); + + // 全屏时:如果编辑框仍在显示但焦点被外部抢走,则自动把焦点拉回编辑框,保证“有光标”的体验。 + // 同时尊重用户的“点击外部结束编辑”:当检测到 pointerdown 发生在编辑框之外时,短暂抑制 refocus, + // 让 simple-mind-map 正常触发 hide_text_edit。 + useEffect(() => { + if (!effectiveFullscreen) return; + if (typeof document === "undefined") return; + + const getEditWrap = () => + document.querySelector( + ".smm-node-edit-wrap, .smm-richtext-node-edit-wrap", + ) as HTMLElement | null; + + const onPointerDownCapture = (e: Event) => { + if (!textEditOpenRef.current) return; + const wrap = getEditWrap(); + if (!wrap) return; + const target = e.target as Node | null; + if (target && wrap.contains(target)) return; + // 用户点击了编辑框外部:允许结束编辑(暂时不强制拉回焦点) + suppressTextEditRefocusUntilRef.current = Date.now() + 800; + }; + + const onFocusInCapture = (e: Event) => { + if (!textEditOpenRef.current) return; + const now = Date.now(); + if (now < suppressTextEditRefocusUntilRef.current) return; + const wrap = getEditWrap(); + if (!wrap) return; + const style = window.getComputedStyle(wrap); + if (style.display === "none" || style.visibility === "hidden") return; + const target = e.target as Node | null; + if (target && wrap.contains(target)) return; + + // 节流:避免 focusin -> refocus -> focusin 的循环 + if (now - lastTextEditRefocusAtRef.current < 120) return; + lastTextEditRefocusAtRef.current = now; + + const editorEl = + (wrap.querySelector(".ql-editor") as HTMLElement | null) ?? wrap; + window.setTimeout(() => { + if (!textEditOpenRef.current) return; + if (Date.now() < suppressTextEditRefocusUntilRef.current) return; + const curWrap = getEditWrap(); + if (!curWrap) return; + const curStyle = window.getComputedStyle(curWrap); + if (curStyle.display === "none" || curStyle.visibility === "hidden") + return; + const active = document.activeElement as HTMLElement | null; + if (active && curWrap.contains(active)) return; + const focusTarget = + (curWrap.querySelector(".ql-editor") as HTMLElement | null) ?? curWrap; + try { + focusTarget.focus({ preventScroll: true }); + } catch { + // ignore + } + }, 0); + }; + + document.addEventListener("pointerdown", onPointerDownCapture, true); + document.addEventListener("mousedown", onPointerDownCapture, true); + document.addEventListener("focusin", onFocusInCapture, true); + return () => { + document.removeEventListener("pointerdown", onPointerDownCapture, true); + document.removeEventListener("mousedown", onPointerDownCapture, true); + document.removeEventListener("focusin", onFocusInCapture, true); + }; + }, [effectiveFullscreen]); + const exitLocalFullscreen = useCallback(() => { setActiveSidebar(null); if (fullscreen) { @@ -954,8 +1092,15 @@ const MindmapBlockView = ({ // 否则切换视图时会用旧数据,表现为“看起来没保存” const safe = canonicalizeMindmapData(data); initialDataRef.current = safe; - window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); - editor.updateBlock(block, { props: { ...block.props, data: safe } }); + window.localStorage.setItem(autosaveKey, JSON.stringify(safe)); + // 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror + // 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框 + // 被迫退出(表现为“有光标但过一会儿就消失”)。 + // 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载) + // 时再统一把最新数据写回 block。 + if (!effectiveFullscreen) { + editor.updateBlock(block, { props: { ...block.props, data: safe } }); + } if (docId) { // 同步到本地文件 + Supabase(弱依赖) fetch(`/api/mindmap/${docId}/${mindmapId}`, { @@ -978,7 +1123,7 @@ const MindmapBlockView = ({ .catch((err) => console.warn("思维导图同步失败", err)); } }, - [autosaveKey, block, docId, editor, mindmapId], + [autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen], ); useEffect(() => { persistDataRef.current = persistData; @@ -1288,8 +1433,21 @@ const MindmapBlockView = ({ useLeftKeySelectionRightKeyDrag: boolean; createNewNodeBehavior: string; iconList: unknown[]; + customInnerElsAppendTo?: HTMLElement; + nodeTextEditZIndex?: number; }) => MindMapInstance; - const MindMapCtor = MindMap as unknown as MindMapConstructor; + const MindMapCtor = MindMap as unknown as MindMapConstructor; + + // Portal 全屏时,simple-mind-map 的节点文本编辑框会 append 到 body,并受 z-index 影响: + // 若 z-index 低于我们的全屏覆盖层,会出现“进入编辑态但看不到独立输入框/光标”的问题。 + // 这里显式提高节点编辑框 z-index,并把内部浮层挂到 wrapper 上,确保全屏下可见且不会被遮挡。 + const fullscreenTextEditOptions = effectiveFullscreen + ? { + // 让编辑框仍 append 到 body(库默认行为),只提高 z-index,避免被全屏覆盖层挡住。 + // 注意:如果把编辑框 append 到 wrapper(overflow-hidden),可能会影响 box-shadow/可见性。 + nodeTextEditZIndex: 100000, + } + : null; const instance = new MindMapCtor({ el: hostEl, @@ -1308,6 +1466,7 @@ const MindmapBlockView = ({ ...nodeIconList, ...(iconConfig as unknown[]), ]), + ...(fullscreenTextEditOptions ?? {}), }); createdInstance = instance; mindmapRef.current = instance; @@ -1433,6 +1592,12 @@ const MindmapBlockView = ({ const renderer = instance.renderer; const root = getRootNode(instance); if (root) { + // 用户正在编辑节点文本时,不要清空 active 列表/切回根节点,避免打断编辑(光标/输入框消失)。 + if (textEditOpenRef.current) { + pendingRenderEndActivateRootRef.current = true; + mindmapReadyRef.current = true; + return; + } if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList(); if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true); if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root); @@ -1530,6 +1695,11 @@ const MindmapBlockView = ({ } // 若首次渲染时 root 仍未就绪,设置兜底延迟激活 window.setTimeout(() => { + // 全屏下快速进入节点编辑态时,兜底激活会打断编辑;延后到 hide_text_edit 再处理。 + if (textEditOpenRef.current) { + pendingInitActivateRootRef.current = true; + return; + } const root = getRootNode(instance); if (!root) return; if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();