Fix canvas transfer timeouts and move images out of snapshots

This commit is contained in:
2026-11-06 21:51:13 +08:00
parent af844131ed
commit 570cd53fc3
21 changed files with 460 additions and 48 deletions
+27 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { EdgeChange, NodeChange } from "@xyflow/react";
import { prepareManagedImages } from "@/lib/canvas-assets";
import { api, ApiError } from "@/lib/api";
import { canvasReducer, decodeCanvas, type CanvasAction, type CanvasDocument, type CanvasHistory, type CanvasNode } from "@/lib/canvas";
import { CanvasSync, canvasFingerprint, canvasPath, decodeSnapshot, readLocalRecovery, type CanvasSnapshot } from "@/lib/canvas-sync";
@@ -24,6 +25,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const retryOperation = useRef<(() => Promise<CanvasCommandResult>) | null>(null);
const locked = useRef(false), mounted = useRef(true), pause = useRef(paused); pause.current = paused;
const writers = useRef(new Map<string, CanvasSync>());
const migrationAbort = useRef<AbortController | null>(null);
const loading = useRef(new Set<string>());
const [retryLoad, setRetryLoad] = useState(0);
const [recoveries, setRecoveries] = useState(new Map<string, CanvasDocument>());
@@ -54,7 +56,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
// Initialization never writes a snapshot to the server.
if (snapshot && !writers.current.has(root)) install(root, snapshot);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; migrationAbort.current?.abort(); }; }, []);
const update = (boardId: string, action: CanvasAction) => {
if (locked.current || retryOperation.current) return;
const before = current.current.get(boardId); if (!before) return;
@@ -74,7 +76,12 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
const adjusted = referenceAfterMoves(root, current.current, view.current.boards, ref.boardId, moves);
const next = { ...change, id: ref.id };
if (next.type === "position" && next.position) next.position = { x: (next.position.x - adjusted.origin.x) / ref.scale, y: (next.position.y - adjusted.origin.y) / ref.scale };
if (next.type === "dimensions" && next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
if (next.type === "dimensions") {
// ResizeObserver measurements are editor state. Only an explicit resize
// gesture may write node dimensions back to the owning snapshot.
if (!next.resizing) next.setAttributes = false;
if (next.dimensions) next.dimensions = { width: next.dimensions.width / ref.scale, height: next.dimensions.height / ref.scale };
}
groups.set(ref.boardId, [...(groups.get(ref.boardId) || []), next]);
}
for (const [boardId, changes] of groups) update(boardId, { type: "nodes", changes });
@@ -148,7 +155,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
for (const [boardId, document] of current.current) try {
if (pendingRecovery.current.has(boardId)) continue;
const writer = writers.current.get(boardId);
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.saved !== canvasFingerprint(document) : false }));
localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false }));
} catch { setError("local_backup_failed"); }
}, 250);
const save = setTimeout(() => {
@@ -162,7 +169,7 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
if (pause.current) return;
for (const [boardId, document] of current.current) {
if (pendingRecovery.current.has(boardId)) continue;
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.saved !== canvasFingerprint(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
try { const writer = writers.current.get(boardId); localStorage.setItem(`endoleap_canvas_v1:${workspace}:${boardId}:recovery`, JSON.stringify({ document: decodeCanvas(JSON.stringify(document)), baseVersion: writer?.version || 1, dirty: writer ? writer.needsSave(document) : false })); } catch { /* Existing draft is retained if storage is full. */ }
}
};
window.addEventListener("pagehide", backup);
@@ -267,6 +274,21 @@ export function useCanvasTree(root: string, workspace: string, initial: CanvasDo
}
tick((v) => v + 1);
}
async function migrateImages() {
return exclusive(async () => {
await flush();
const before = current.current.get(root)!;
const controller = new AbortController(); migrationAbort.current = controller;
let document: CanvasDocument;
try { document = await prepareManagedImages(before, api, workspace, controller.signal); }
finally { migrationAbort.current = null; }
if (!mounted.current || controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
undo.current.push({ boardId: root, before }); redo.current = [];
publish(new Map(current.current).set(root, document));
const writer = writers.current.get(root)!;
await writer.enqueue(document); await writer.settled(); if (writer.error) throw new Error(writer.error);
});
}
const retry = async () => { setError(""); setRetryLoad((v) => v + 1); for (const writer of writers.current.values()) await writer.retry(); };
return { documents, current, views, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
return { documents, current, views, migrateImages, pendingCommand, retryPending: () => retryOperation.current ? exclusive(retryOperation.current) : Promise.resolve(null), prepareApply, recoveries, resolveRecovery, ensure, exportDocuments, dispatch, nodeAction, nodesChanged, edgesChanged, patchEdge, select, flush, command, refresh, read, busy: busy || pendingCommand, error, status, retry, undo: () => undoAction(), redo: () => undoAction(true), canUndo: !!undo.current.length, canRedo: !!redo.current.length, writers, rootDocument: documents.get(root)!, setError };
}