0.4.0 convex及界面修改

This commit is contained in:
liaibo
2026-02-01 08:47:40 +08:00
parent d1f055f51a
commit af92c4b149
636 changed files with 7522 additions and 1815 deletions
@@ -34,6 +34,9 @@ import { Input } from "@/components/ui/input";
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
import iconConfig from "./mindmapIconConfig";
import { emitAssetsChanged } from "@/lib/events";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { api } from "@/lib/convex/api";
import { useQuery } from "convex/react";
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
const loadIconModules = async () => {
@@ -462,6 +465,11 @@ const MindmapBlockView = ({
const mindmapReadyRef = useRef(false);
const mindmapRef = useRef<MindMapInstance | null>(null);
const hasLocalEditsRef = useRef(false);
const lastLocalEditAtRef = useRef(0);
const markLocalEdited = useCallback(() => {
hasLocalEditsRef.current = true;
lastLocalEditAtRef.current = Date.now();
}, []);
const applyingRemoteRef = useRef(false);
const [canBack, setCanBack] = useState(false);
const [canForward, setCanForward] = useState(false);
@@ -513,7 +521,10 @@ const MindmapBlockView = ({
if (!docId) return;
void (async () => {
try {
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
// 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。
const res = await fetch(
`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`,
);
const json: unknown = await res.json().catch(() => null);
if (!res.ok) return;
if (cancelled) return;
@@ -530,6 +541,7 @@ const MindmapBlockView = ({
useEffect(() => {
hasLocalEditsRef.current = false;
lastLocalEditAtRef.current = 0;
applyingRemoteRef.current = false;
}, [docId]);
@@ -537,6 +549,15 @@ const MindmapBlockView = ({
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
return `${STORAGE_PREFIX}${mindmapId}`;
}, [docId, mindmapId]);
// 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。
const lastLocalSavedAtRef = useRef<string | null>(null);
const lastAppliedRemoteUpdatedAtRef = useRef<string | null>(null);
const remoteMindmap = useQuery(
api.mindmaps.get,
isConvexEnabled() && docId ? { docId, mindmapId } : "skip",
);
const initialDataRef = useRef<unknown>(null);
if (initialDataRef.current === null) {
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
@@ -584,6 +605,83 @@ const MindmapBlockView = ({
};
}, [docId, mindmap, mindmapId]);
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
useEffect(() => {
if (!remoteMindmap || typeof remoteMindmap !== "object") return;
const meta = (remoteMindmap as any).meta as Record<string, unknown> | undefined;
const deletedAt = typeof meta?.deleted_at === "string" ? (meta.deleted_at as string) : null;
const updatedAt = typeof meta?.updated_at === "string" ? (meta.updated_at as string) : null;
if (deletedAt) {
if (!docId || deletingRef.current) return;
deletingRef.current = true;
try {
window.localStorage.removeItem(autosaveKey);
} catch {
// ignore
}
// 全屏页:退出到文档页
if (effectiveFullscreen && typeof onExitFullscreen === "function") {
window.alert("该思维导图已被删除(已移入垃圾桶),将返回页面。");
onExitFullscreen();
return;
}
// 嵌入编辑器:复用编辑器监听链路移除块
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
return;
}
if (!updatedAt) return;
if (lastAppliedRemoteUpdatedAtRef.current === updatedAt) return;
// 如果这是本端刚刚保存产生的回放,跳过应用,避免清空历史/打断输入
if (lastLocalSavedAtRef.current && lastLocalSavedAtRef.current === updatedAt) {
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
return;
}
// 本地仍有未同步编辑时,不覆盖
if (hasLocalEditsRef.current || deletingRef.current) return;
const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData);
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
initialDataRef.current = incoming;
try {
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
} catch {
// ignore
}
if (!effectiveFullscreen) {
try {
editor.updateBlock(block, { props: { ...block.props, data: incoming } });
} catch {
// ignore
}
}
if (mindmap) {
applyingRemoteRef.current = true;
try {
mindmap.setData(incoming);
mindmap.command.clearHistory();
} finally {
window.setTimeout(() => {
applyingRemoteRef.current = false;
}, 0);
}
}
}, [
autosaveKey,
block,
docId,
editor,
effectiveFullscreen,
mindmap,
mindmapId,
onExitFullscreen,
remoteMindmap,
]);
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
useEffect(() => {
const onPointerDownCapture = (e: Event) => {
@@ -956,7 +1054,7 @@ const MindmapBlockView = ({
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
// 切换全屏/刷新后不会丢失。
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -974,7 +1072,7 @@ const MindmapBlockView = ({
if (!node) return;
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -993,7 +1091,7 @@ const MindmapBlockView = ({
const inst = ensureActiveBefore(mm);
if (!inst) return;
inst.execCommand?.("REMOVE_NODE");
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1029,7 +1127,7 @@ const MindmapBlockView = ({
const copyData = renderer.beingCopyData ?? null;
if (copyData) {
inst.execCommand?.("PASTE_NODE", copyData);
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1039,7 +1137,7 @@ const MindmapBlockView = ({
return;
}
renderer.paste?.();
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1053,7 +1151,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("keydown", onKeyDownCapture, true);
};
}, []);
}, [markLocalEdited]);
// 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。
// 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时,
@@ -1114,7 +1212,7 @@ const MindmapBlockView = ({
}
// 兜底持久化:避免快速切换视图导致“看起来没保存”
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1127,7 +1225,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("paste", onPasteCapture, true);
};
}, []);
}, [markLocalEdited]);
// 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。
// 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。
@@ -1243,7 +1341,7 @@ const MindmapBlockView = ({
// ignore
}
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
if (snapshot) persistDataRef.current?.(snapshot);
@@ -1256,7 +1354,7 @@ const MindmapBlockView = ({
return () => {
window.removeEventListener("beforeinput", onBeforeInputCapture, true);
};
}, []);
}, [markLocalEdited]);
const persistData = useCallback(
(data: unknown) => {
@@ -1279,14 +1377,30 @@ const MindmapBlockView = ({
editor.updateBlock(block, { props: { ...block.props, data: safe } });
}
if (docId) {
// 同步到本地文件 + Supabase(弱依赖)
// 同步到本地文件 + Convex(弱依赖)
const requestStartedAt = Date.now();
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: safe }),
})
.then((resp) => {
.then(async (resp) => {
if (resp.ok) {
try {
const payload = (await resp.json().catch(() => null)) as any;
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
if (updatedAt) {
lastLocalSavedAtRef.current = updatedAt;
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
// 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了”
if (lastLocalEditAtRef.current <= requestStartedAt) {
hasLocalEditsRef.current = false;
}
}
} catch {
// ignore
}
const fileName = `mindmap-${mindmapId}.json`;
emitAssetsChanged(docId, {
id: mindmapId,
@@ -1699,7 +1813,7 @@ const MindmapBlockView = ({
!deletingRef.current &&
shouldPersistAfterCommand(cmd)
) {
hasLocalEditsRef.current = true;
markLocalEdited();
try {
const snapshot =
instance.getData?.(true) ?? instance.getData?.() ?? null;
@@ -1845,13 +1959,13 @@ const MindmapBlockView = ({
});
instance.on?.("painter_start", () => setPainterMode(true));
instance.on?.("painter_end", () => setPainterMode(false));
instance.on?.("data_change", () => {
if (!applyingRemoteRef.current) {
hasLocalEditsRef.current = true;
}
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
instance.on?.("data_change", () => {
if (!applyingRemoteRef.current) {
markLocalEdited();
}
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
const snapshot =
instance.getData?.(true) ?? instance.getData?.() ?? null;
if (snapshot) schedulePersist(snapshot);
@@ -2688,7 +2802,7 @@ const MindmapBlockView = ({
editor.removeBlocks([block.id]);
return;
}
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
if (!confirmed) return;
deletingRef.current = true;
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });