0.1.02思维导图全屏/lightrag修复/luckysheet修复

This commit is contained in:
liaibo
2026-01-06 05:21:53 +08:00
parent fb6832c30d
commit 9884ea0ea6
12 changed files with 570 additions and 199 deletions
@@ -1,7 +1,15 @@
"use client";
import "simple-mind-map/dist/simpleMindMap.esm.css";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { createReactBlockSpec } from "@blocknote/react";
import type { Block, BlockNoteEditor } from "@blocknote/core";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
@@ -230,6 +238,9 @@ const MindmapBlockView = ({
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [mindmap, setMindmap] = useState<MindMapInstance | null>(null);
const mindmapReadyRef = useRef(false);
const mindmapRef = useRef<MindMapInstance | null>(null);
const hasLocalEditsRef = useRef(false);
const applyingRemoteRef = useRef(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
const [activeNodes, setActiveNodes] = useState<unknown[]>([]);
@@ -238,6 +249,12 @@ const MindmapBlockView = ({
const [activeSidebar, setActiveSidebar] = useState<SidebarPanel | null>(null);
const [showNoteModal, setShowNoteModal] = useState(false);
const [noteContent, setNoteContent] = useState("");
const [localFullscreen, setLocalFullscreen] = useState(false);
const effectiveFullscreen = fullscreen || localFullscreen;
const wrapperRef = useRef<HTMLDivElement | null>(null);
const hotkeyScopeRef = useRef(false);
const recentNodeDblclickRef = useRef(false);
const [fullscreenApiActive, setFullscreenApiActive] = useState(false);
const getRootNode = useCallback((mm?: MindMapInstance | null) => {
const instance = mm ?? mindmap;
@@ -254,6 +271,11 @@ const MindmapBlockView = ({
[block.props.docId, currentDocumentId],
);
useEffect(() => {
hasLocalEditsRef.current = false;
applyingRemoteRef.current = false;
}, [docId]);
const autosaveKey = useMemo(
() => `${STORAGE_PREFIX}${docId || block.id}`,
[block.id, docId],
@@ -283,10 +305,19 @@ const MindmapBlockView = ({
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return;
initialDataRef.current = data;
if (mindmap) {
mindmap.setData(data);
mindmap.command.clearHistory();
applyingRemoteRef.current = true;
try {
mindmap.setData(data);
mindmap.command.clearHistory();
} finally {
window.setTimeout(() => {
applyingRemoteRef.current = false;
}, 0);
}
}
} catch (error) {
console.warn("加载本地/远端思维导图失败", error);
@@ -297,6 +328,220 @@ const MindmapBlockView = ({
};
}, [docId, mindmap]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
useEffect(() => {
const onPointerDownCapture = (e: Event) => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const target = e.target as Node | null;
hotkeyScopeRef.current = !!(target && wrapper.contains(target));
};
document.addEventListener("pointerdown", onPointerDownCapture, true);
document.addEventListener("mousedown", onPointerDownCapture, true);
return () => {
document.removeEventListener("pointerdown", onPointerDownCapture, true);
document.removeEventListener("mousedown", onPointerDownCapture, true);
};
}, []);
// 标记“节点双击”事件,避免和“画布双击进入全屏”产生冲突
useEffect(() => {
if (!mindmap) return;
const mark = () => {
recentNodeDblclickRef.current = true;
window.setTimeout(() => {
recentNodeDblclickRef.current = false;
}, 0);
};
mindmap.on?.("node_dblclick", mark);
return () => {
mindmap.off?.("node_dblclick", mark);
};
}, [mindmap]);
const exitLocalFullscreen = useCallback(() => {
setActiveSidebar(null);
if (fullscreen) return;
if (typeof document === "undefined") {
setLocalFullscreen(false);
return;
}
if (document.fullscreenElement) {
document
.exitFullscreen()
.catch(() => {
// ignore
})
.finally(() => setLocalFullscreen(false));
return;
}
setLocalFullscreen(false);
}, [fullscreen]);
const enterLocalFullscreen = useCallback(() => {
if (fullscreen) return;
setLocalFullscreen(true);
setActiveSidebar(null);
if (typeof document === "undefined") return;
if (!document.fullscreenEnabled) return;
if (document.fullscreenElement) return;
// 必须在“用户手势回调”中同步调用,避免 Fullscreen API 被浏览器拒绝
try {
const target = document.documentElement as any;
const p = target?.requestFullscreen?.();
if (p && typeof p.catch === "function") {
p.catch(() => {
// ignore:失败则保持 Portal 伪全屏
});
}
} catch {
// ignore:失败则保持 Portal 伪全屏
}
}, [fullscreen]);
// 沉浸式全屏:锁定页面滚动、支持 ESC 退出(仅对内嵌全屏生效)
useEffect(() => {
if (!localFullscreen) return;
if (typeof document === "undefined") return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
// 进入全屏后尽量把焦点放到思维导图容器上,保证快捷键立即生效
queueMicrotask(() => {
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
});
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
if (fullscreen) return; // 外部传入的 fullscreen 不在这里处理
e.preventDefault();
e.stopPropagation();
exitLocalFullscreen();
};
window.addEventListener("keydown", onKeyDown, true);
return () => {
window.removeEventListener("keydown", onKeyDown, true);
document.body.style.overflow = prevOverflow;
};
}, [localFullscreen, fullscreen, exitLocalFullscreen]);
// “真全屏”:使用 Fullscreen API 隐藏浏览器/桌面窗口的外层 UIElectron/Web 都可用)
useEffect(() => {
if (typeof document === "undefined") return;
const onFsChange = () => {
const active = Boolean(document.fullscreenElement);
setFullscreenApiActive(active);
// 用户按 ESC 退出浏览器全屏时,同步退出沉浸式全屏界面
if (!active && localFullscreen) {
setLocalFullscreen(false);
}
};
document.addEventListener("fullscreenchange", onFsChange);
return () => {
document.removeEventListener("fullscreenchange", onFsChange);
};
}, [localFullscreen]);
// 让 Enter/Tab/复制/粘贴在“思维导图块”内生效(避免 BlockNote 抢走快捷键)
useLayoutEffect(() => {
const onKeyDownCapture = (e: KeyboardEvent) => {
if (!hotkeyScopeRef.current) return;
const mm = mindmapRef.current;
if (!mm || !mindmapReadyRef.current) return;
const target = e.target as HTMLElement | null;
const editClasses = (mm as any)?.editNodeClassList as string[] | undefined;
const wrapper = wrapperRef.current;
const isInWrapper = Boolean(wrapper && target && wrapper.contains(target));
if (target && isInWrapper) {
const tag = target.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
// contenteditable 输入中不拦截:避免破坏节点文本编辑/粘贴
if ((target as any).isContentEditable) return;
const editableAncestor = target.closest?.('[contenteditable="true"]');
if (editableAncestor) return;
if (editClasses && target.classList) {
for (const cls of editClasses) {
if (cls && target.classList.contains(cls)) return;
}
}
}
const isMod = e.ctrlKey || e.metaKey;
const key = e.key;
const lower = key.toLowerCase();
const stop = () => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
};
const pickTargetNode = (inst: MindMapInstance) => {
const renderer = inst.renderer as any;
const active: any[] = renderer?.activeNodeList ?? [];
const last: any[] = renderer?.lastActiveNodeList ?? [];
const root = renderer?.root ?? renderer?.renderTree?._node ?? null;
return (active && active[0]) || (last && last[0]) || root || null;
};
if (key === "Enter" && !e.shiftKey && !e.altKey && !isMod) {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const node = pickTargetNode(inst);
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_NODE", false, [node]);
return;
}
if (key === "Tab" && !e.shiftKey && !e.altKey && !isMod) {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const node = pickTargetNode(inst);
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
return;
}
if (isMod && lower === "c") {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const renderer = inst.renderer as any;
if (typeof renderer?.copy === "function") renderer.copy();
return;
}
if (isMod && lower === "v") {
stop();
const inst = ensureActiveBefore(mm);
if (!inst) return;
const renderer = inst.renderer as any;
const copyData = renderer?.beingCopyData ?? null;
if (copyData) {
inst.execCommand?.("PASTE_NODE", copyData);
return;
}
if (typeof renderer?.paste === "function") renderer.paste();
}
};
window.addEventListener("keydown", onKeyDownCapture, true);
return () => {
window.removeEventListener("keydown", onKeyDownCapture, true);
};
}, []);
const persistData = useCallback(
(data: unknown) => {
if (!editor) return;
@@ -328,7 +573,7 @@ const MindmapBlockView = ({
const debouncedPersist = useDebouncedCallback((data: unknown) => {
persistData(data);
}, 500);
}, 800);
const initialSyncDone = useRef(false);
useEffect(() => {
@@ -559,6 +804,7 @@ const MindmapBlockView = ({
]),
}) as MindMapInstance;
createdInstance = instance;
mindmapRef.current = instance;
// 尽早标记为可用:主页面通过 `/mind` 插入后,若依赖 render_end/尺寸探测,可能导致按钮永远不可用
//render_end 可能早于监听注册触发,或容器尺寸长时间为 0)。命令本身不需要等待 render_end。
mindmapReadyRef.current = true;
@@ -629,7 +875,6 @@ const MindmapBlockView = ({
if (renderer?.emitNodeActiveEvent) renderer.emitNodeActiveEvent(root);
}
mindmapReadyRef.current = true;
console.log("[mindmap] render_end activeLen", renderer?.activeNodeList?.length ?? 0);
centerAndFit();
};
@@ -673,6 +918,9 @@ const MindmapBlockView = ({
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
instance.on?.("data_change", (data: unknown) => {
if (!applyingRemoteRef.current) {
hasLocalEditsRef.current = true;
}
debouncedPersist(data);
});
const renderer = instance.renderer;
@@ -682,7 +930,6 @@ const MindmapBlockView = ({
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]);
}
// 若首次渲染时 root 仍未就绪,设置兜底延迟激活
@@ -692,7 +939,6 @@ const MindmapBlockView = ({
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);
@@ -704,6 +950,32 @@ const MindmapBlockView = ({
return () => {
destroyed = true;
try {
// 切换“内嵌/全屏”会导致实例重建:这里尽量在销毁前同步一次数据,避免丢失最后一次编辑
if (createdInstance && typeof window !== "undefined") {
const data =
createdInstance.getData?.(true) ?? createdInstance.getData?.();
if (data) {
try {
window.localStorage.setItem(autosaveKey, JSON.stringify(data));
} catch {
// ignore
}
try {
editor?.updateBlock(block, { props: { ...block.props, data } });
} catch {
// ignore
}
if (docId) {
fetch(`/api/mindmap/${docId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
}).catch(() => {
// ignore
});
}
}
}
createdInstance?.destroy();
} catch {
// ignore
@@ -716,10 +988,11 @@ const MindmapBlockView = ({
}
}
setMindmap(null);
mindmapRef.current = null;
mindmapReadyRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [block.id]);
}, [block.id, effectiveFullscreen]);
const getInstanceCandidate = () =>
mindmap ??
@@ -1463,33 +1736,61 @@ const MindmapBlockView = ({
</div>
) : null;
if (fullscreen) {
return (
<div className="relative h-screen w-screen overflow-hidden bg-white">
<div className="fixed left-1/2 top-4 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
if (effectiveFullscreen) {
const fullscreenView = (
<div
ref={wrapperRef}
data-testid="mindmap-fullscreen"
tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
>
<div className="fixed left-0 right-0 top-0 z-30 flex h-12 items-center justify-between border-b border-gray-200 bg-white/90 px-4 backdrop-blur">
<div className="text-sm font-medium text-gray-700">
{fullscreenApiActive ? "(全屏)" : ""}
</div>
<button
type="button"
title="退出全屏"
className="rounded p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={exitLocalFullscreen}
>
<span className="text-lg leading-none">×</span>
</button>
</div>
<div className="fixed left-1/2 top-14 z-20 flex w-fit -translate-x-1/2 transform items-center justify-center gap-4">
<MindmapToolbar {...toolbarProps} />
</div>
<div className="relative h-full w-full">
<div className="relative h-full w-full pt-12">
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
onSelect={setActiveSidebar}
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
onSelect={setActiveSidebar}
/>
{/* @ts-expect-error mindmap type */}
<MindmapSidebar
mindmap={mindmap}
<MindmapSidebar
mindmap={mindmap}
activeNodes={activeNodes} // @ts-expect-error type
activeTab={activeSidebar}
onClose={() => setActiveSidebar(null)}
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={fullscreen}
fullscreen={effectiveFullscreen}
toggleFullscreen={
fullscreen
? undefined
: () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
@@ -1503,19 +1804,53 @@ const MindmapBlockView = ({
</div>
</div>
);
if (typeof document === "undefined") return null;
return createPortal(fullscreenView, document.body);
}
return (
<div className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm">
<div
ref={wrapperRef}
data-testid="mindmap-embed"
tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
>
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
<div className="w-full">
<MindmapToolbar {...toolbarProps} />
</div>
</div>
<div className="relative h-[520px] w-full overflow-hidden bg-white">
<div
data-testid="mindmap-stage"
className="relative h-[520px] w-full overflow-hidden bg-white"
onPointerDown={() => {
hotkeyScopeRef.current = true;
// 点击后把焦点落到容器上,避免快捷键被编辑器抢走
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}}
onMouseDown={() => {
hotkeyScopeRef.current = true;
// 兼容:某些环境下 pointer 事件不触发
try {
wrapperRef.current?.focus?.({ preventScroll: true });
} catch {
// ignore
}
}}
onDoubleClick={() => {
if (recentNodeDblclickRef.current) return;
enterLocalFullscreen();
}}
>
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
contentEditable={false}
/>
@@ -1532,7 +1867,15 @@ const MindmapBlockView = ({
/>
<MindmapNavigator
mindmap={mindmap}
fullscreen={fullscreen}
fullscreen={effectiveFullscreen}
toggleFullscreen={
fullscreen
? undefined
: () => {
if (localFullscreen) exitLocalFullscreen();
else enterLocalFullscreen();
}
}
onToggleMiniMap={() => setShowMiniMap(!showMiniMap)}
miniMapOpen={showMiniMap}
/>
@@ -7,7 +7,7 @@ type Props = {
onSelect: (value: SidebarPanel | null) => void;
};
export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
const [show, setShow] = React.useState(true);
return (
@@ -15,35 +15,64 @@ export const MindmapSidebarTrigger = ({ activeSidebar, onSelect }: Props) => {
className={`absolute top-1/2 z-30 flex -translate-y-1/2 transition-all duration-300 ${
activeSidebar ? "right-[300px]" : "right-0"
}`}
contentEditable={false}
onPointerDownCapture={(e) => e.stopPropagation()}
onMouseDownCapture={(e) => e.stopPropagation()}
>
<div
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md hover:w-6 hover:-left-6 transition-all"
onClick={() => setShow(!show)}
style={{ display: show ? "flex" : "none" }}
data-testid="mindmap-sidebar-collapse-handle"
className="absolute -left-4 top-1/2 flex h-12 w-4 -translate-y-1/2 cursor-pointer items-center justify-center rounded-l-md bg-blue-500 text-white shadow-md transition-all hover:-left-6 hover:w-6"
onClick={(e) => {
e.stopPropagation();
setShow((prev) => {
const next = !prev;
if (!next) {
// 避免在 setShow 的 updater 内触发父组件 setState,导致 React 警告
queueMicrotask(() => onSelect(null));
}
return next;
});
}}
>
<ChevronRight className="h-3 w-3" />
<ChevronRight
className={`h-3 w-3 transition-transform ${show ? "" : "rotate-180"}`}
/>
</div>
<div className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg">
{sidebarTriggers
.filter((item) => item.visible !== false)
.map((item) => {
const target = item.target;
const isActive = activeSidebar === target;
return (
<button
key={item.value}
onClick={() => onSelect(isActive ? null : target)}
className={`flex h-16 w-16 flex-col items-center justify-center gap-1 border-b border-gray-100 p-2 text-gray-600 transition-colors hover:bg-gray-50 last:border-0 ${
isActive ? "bg-blue-50 text-blue-600 font-medium" : ""
}`}
>
<i className={`iconfont ${item.iconClass} text-[18px] leading-none`} />
<span className="text-xs whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
{show ? (
<div
data-testid="mindmap-sidebar-trigger-panel"
className="flex flex-col overflow-hidden rounded-l-lg border border-gray-200 bg-white shadow-lg"
>
{sidebarTriggers
.filter((item) => item.visible !== false)
.map((item) => {
const target = item.target;
const isActive = activeSidebar === target;
return (
<button
key={item.value}
type="button"
contentEditable={false}
onPointerDown={(ev) => ev.stopPropagation()}
onMouseDown={(ev) => ev.stopPropagation()}
onClick={(ev) => {
ev.stopPropagation();
onSelect(isActive ? null : target);
}}
className={`flex h-16 w-16 flex-col items-center justify-center gap-1 border-b border-gray-100 p-2 text-gray-600 transition-colors hover:bg-gray-50 last:border-0 ${
isActive ? "bg-blue-50 font-medium text-blue-600" : ""
}`}
>
<i
className={`iconfont ${item.iconClass} text-[18px] leading-none`}
/>
<span className="whitespace-nowrap text-xs">{item.label}</span>
</button>
);
})}
</div>
) : null}
</div>
);
};