0.1.12全屏编辑修复
This commit is contained in:
@@ -332,6 +332,11 @@ const MindmapBlockView = ({
|
|||||||
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
const persistDataRef = useRef<((data: unknown) => void) | null>(null);
|
||||||
const recentNodeDblclickRef = useRef(false);
|
const recentNodeDblclickRef = useRef(false);
|
||||||
const deletingRef = 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 getRootNode = useCallback((mm?: MindMapInstance | null) => {
|
||||||
const instance = mm ?? mindmap;
|
const instance = mm ?? mindmap;
|
||||||
@@ -464,7 +469,6 @@ const MindmapBlockView = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wrapper = wrapperRef.current;
|
const wrapper = wrapperRef.current;
|
||||||
if (!wrapper) return;
|
if (!wrapper) return;
|
||||||
if (effectiveFullscreen) return;
|
|
||||||
|
|
||||||
const stopBubble = (e: Event) => {
|
const stopBubble = (e: Event) => {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
@@ -488,19 +492,22 @@ const MindmapBlockView = ({
|
|||||||
hotkeyScopeRef.current = true;
|
hotkeyScopeRef.current = true;
|
||||||
lastInteractionAtRef.current = Date.now();
|
lastInteractionAtRef.current = Date.now();
|
||||||
|
|
||||||
// 同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定
|
// 内嵌视图:同步/异步都尝试一次,把焦点拉到 wrapper,保证快捷键稳定。
|
||||||
try {
|
// 全屏(Portal)视图:不能强制 focus wrapper,否则会打断节点双击编辑输入框的焦点。
|
||||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
if (!effectiveFullscreen) {
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
window.setTimeout(() => {
|
|
||||||
try {
|
try {
|
||||||
wrapperRef.current?.focus?.({ preventScroll: true });
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}, 0);
|
window.setTimeout(() => {
|
||||||
|
try {
|
||||||
|
wrapperRef.current?.focus?.({ preventScroll: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
wrapper.addEventListener("mousedown", stopBubble);
|
wrapper.addEventListener("mousedown", stopBubble);
|
||||||
@@ -526,6 +533,137 @@ const MindmapBlockView = ({
|
|||||||
};
|
};
|
||||||
}, [mindmap]);
|
}, [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(() => {
|
const exitLocalFullscreen = useCallback(() => {
|
||||||
setActiveSidebar(null);
|
setActiveSidebar(null);
|
||||||
if (fullscreen) {
|
if (fullscreen) {
|
||||||
@@ -954,8 +1092,15 @@ const MindmapBlockView = ({
|
|||||||
// 否则切换视图时会用旧数据,表现为“看起来没保存”
|
// 否则切换视图时会用旧数据,表现为“看起来没保存”
|
||||||
const safe = canonicalizeMindmapData(data);
|
const safe = canonicalizeMindmapData(data);
|
||||||
initialDataRef.current = safe;
|
initialDataRef.current = safe;
|
||||||
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
window.localStorage.setItem(autosaveKey, JSON.stringify(safe));
|
||||||
editor.updateBlock(block, { props: { ...block.props, data: safe } });
|
// 注意:全屏(Portal 覆盖层)下如果调用 updateBlock,BlockNote/ProseMirror
|
||||||
|
// 可能会在防抖保存时(例如 ~800ms)抢回焦点,导致节点双击编辑的输入框
|
||||||
|
// 被迫退出(表现为“有光标但过一会儿就消失”)。
|
||||||
|
// 因此全屏态只做 localStorage + 后端同步,等退出全屏(实例重建/卸载)
|
||||||
|
// 时再统一把最新数据写回 block。
|
||||||
|
if (!effectiveFullscreen) {
|
||||||
|
editor.updateBlock(block, { props: { ...block.props, data: safe } });
|
||||||
|
}
|
||||||
if (docId) {
|
if (docId) {
|
||||||
// 同步到本地文件 + Supabase(弱依赖)
|
// 同步到本地文件 + Supabase(弱依赖)
|
||||||
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||||
@@ -978,7 +1123,7 @@ const MindmapBlockView = ({
|
|||||||
.catch((err) => console.warn("思维导图同步失败", err));
|
.catch((err) => console.warn("思维导图同步失败", err));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[autosaveKey, block, docId, editor, mindmapId],
|
[autosaveKey, block, docId, editor, mindmapId, effectiveFullscreen],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
persistDataRef.current = persistData;
|
persistDataRef.current = persistData;
|
||||||
@@ -1288,8 +1433,21 @@ const MindmapBlockView = ({
|
|||||||
useLeftKeySelectionRightKeyDrag: boolean;
|
useLeftKeySelectionRightKeyDrag: boolean;
|
||||||
createNewNodeBehavior: string;
|
createNewNodeBehavior: string;
|
||||||
iconList: unknown[];
|
iconList: unknown[];
|
||||||
|
customInnerElsAppendTo?: HTMLElement;
|
||||||
|
nodeTextEditZIndex?: number;
|
||||||
}) => MindMapInstance;
|
}) => 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({
|
const instance = new MindMapCtor({
|
||||||
el: hostEl,
|
el: hostEl,
|
||||||
@@ -1308,6 +1466,7 @@ const MindmapBlockView = ({
|
|||||||
...nodeIconList,
|
...nodeIconList,
|
||||||
...(iconConfig as unknown[]),
|
...(iconConfig as unknown[]),
|
||||||
]),
|
]),
|
||||||
|
...(fullscreenTextEditOptions ?? {}),
|
||||||
});
|
});
|
||||||
createdInstance = instance;
|
createdInstance = instance;
|
||||||
mindmapRef.current = instance;
|
mindmapRef.current = instance;
|
||||||
@@ -1433,6 +1592,12 @@ const MindmapBlockView = ({
|
|||||||
const renderer = instance.renderer;
|
const renderer = instance.renderer;
|
||||||
const root = getRootNode(instance);
|
const root = getRootNode(instance);
|
||||||
if (root) {
|
if (root) {
|
||||||
|
// 用户正在编辑节点文本时,不要清空 active 列表/切回根节点,避免打断编辑(光标/输入框消失)。
|
||||||
|
if (textEditOpenRef.current) {
|
||||||
|
pendingRenderEndActivateRootRef.current = true;
|
||||||
|
mindmapReadyRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
||||||
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true);
|
if (renderer?.addNodeToActiveList) renderer.addNodeToActiveList(root, true);
|
||||||
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
|
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
|
||||||
@@ -1530,6 +1695,11 @@ const MindmapBlockView = ({
|
|||||||
}
|
}
|
||||||
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
|
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
|
// 全屏下快速进入节点编辑态时,兜底激活会打断编辑;延后到 hide_text_edit 再处理。
|
||||||
|
if (textEditOpenRef.current) {
|
||||||
|
pendingInitActivateRootRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const root = getRootNode(instance);
|
const root = getRootNode(instance);
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
if (renderer?.clearActiveNodeList) renderer.clearActiveNodeList();
|
||||||
|
|||||||
Reference in New Issue
Block a user