From 7e1a77c38c518f6740646f4eb0be6598186055a5 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 19:10:07 +0200 Subject: [PATCH] refactor(canvas): debounce and extract local snapshot persistence --- components/canvas/canvas.tsx | 24 ++-- .../use-canvas-local-snapshot-persistence.ts | 105 ++++++++++++++++++ 2 files changed, 113 insertions(+), 16 deletions(-) create mode 100644 components/canvas/use-canvas-local-snapshot-persistence.ts diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index e74f99a..6acd67c 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -42,11 +42,9 @@ import { dropCanvasOpsByEdgeIds, dropCanvasOpsByNodeIds, enqueueCanvasOp, - readCanvasSnapshot, remapCanvasOpNodeId, resolveCanvasOp, resolveCanvasOps, - writeCanvasSnapshot, } from "@/lib/canvas-local-persistence"; import { ackCanvasSyncOp, @@ -140,6 +138,7 @@ import { getImageDimensions } from "./canvas-media-utils"; import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasScissors } from "./canvas-scissors"; import { CanvasSyncProvider } from "./canvas-sync-context"; +import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; interface CanvasInnerProps { canvasId: Id<"canvases">; @@ -1907,7 +1906,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Lokaler State (für flüssiges Dragging) ─────────────────── const nodesRef = useRef(nodes); nodesRef.current = nodes; - const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false); const [connectionDropMenu, setConnectionDropMenu] = useState(null); const connectionDropMenuRef = useRef(null); @@ -1919,19 +1917,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { >(null); const [navTool, setNavTool] = useState("select"); - useEffect(() => { - const snapshot = readCanvasSnapshot(canvasId as string); - if (snapshot) { - setNodes(snapshot.nodes); - setEdges(snapshot.edges); - } - setHasHydratedLocalSnapshot(true); - }, [canvasId]); - - useEffect(() => { - if (!hasHydratedLocalSnapshot) return; - writeCanvasSnapshot(canvasId as string, { nodes, edges }); - }, [canvasId, edges, hasHydratedLocalSnapshot, nodes]); + useCanvasLocalSnapshotPersistence({ + canvasId: canvasId as string, + nodes, + edges, + setNodes, + setEdges, + }); const handleNavToolChange = useCallback((tool: CanvasNavTool) => { if (tool === "scissor") { diff --git a/components/canvas/use-canvas-local-snapshot-persistence.ts b/components/canvas/use-canvas-local-snapshot-persistence.ts new file mode 100644 index 0000000..d37fba5 --- /dev/null +++ b/components/canvas/use-canvas-local-snapshot-persistence.ts @@ -0,0 +1,105 @@ +import { + useCallback, + useEffect, + useRef, + type Dispatch, + type SetStateAction, +} from "react"; +import { + readCanvasSnapshot, + writeCanvasSnapshot, +} from "@/lib/canvas-local-persistence"; + +type UseCanvasLocalSnapshotPersistenceParams = { + canvasId: string; + nodes: TNode[]; + edges: TEdge[]; + setNodes: Dispatch>; + setEdges: Dispatch>; + debounceMs?: number; +}; + +type PendingSnapshot = { + canvasId: string; + nodes: TNode[]; + edges: TEdge[]; +}; + +const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 350; + +export function useCanvasLocalSnapshotPersistence({ + canvasId, + nodes, + edges, + setNodes, + setEdges, + debounceMs = DEFAULT_SNAPSHOT_DEBOUNCE_MS, +}: UseCanvasLocalSnapshotPersistenceParams): void { + const hasHydratedLocalSnapshotRef = useRef(false); + const timeoutRef = useRef | null>(null); + const pendingSnapshotRef = useRef | null>(null); + + const flushPendingSnapshot = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + const pendingSnapshot = pendingSnapshotRef.current; + if (!pendingSnapshot) return; + + writeCanvasSnapshot(pendingSnapshot.canvasId, { + nodes: pendingSnapshot.nodes, + edges: pendingSnapshot.edges, + }); + pendingSnapshotRef.current = null; + }, []); + + useEffect(() => { + hasHydratedLocalSnapshotRef.current = false; + flushPendingSnapshot(); + + const snapshot = readCanvasSnapshot(canvasId); + if (snapshot) { + setNodes(snapshot.nodes); + setEdges(snapshot.edges); + } + + hasHydratedLocalSnapshotRef.current = true; + }, [canvasId, flushPendingSnapshot, setEdges, setNodes]); + + useEffect(() => { + if (!hasHydratedLocalSnapshotRef.current) return; + + pendingSnapshotRef.current = { canvasId, nodes, edges }; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + const pendingSnapshot = pendingSnapshotRef.current; + if (!pendingSnapshot) return; + + writeCanvasSnapshot(pendingSnapshot.canvasId, { + nodes: pendingSnapshot.nodes, + edges: pendingSnapshot.edges, + }); + pendingSnapshotRef.current = null; + timeoutRef.current = null; + }, debounceMs); + }, [canvasId, debounceMs, edges, nodes]); + + useEffect(() => { + return () => flushPendingSnapshot(); + }, [flushPendingSnapshot]); + + useEffect(() => { + window.addEventListener("beforeunload", flushPendingSnapshot); + window.addEventListener("pagehide", flushPendingSnapshot); + return () => { + window.removeEventListener("beforeunload", flushPendingSnapshot); + window.removeEventListener("pagehide", flushPendingSnapshot); + }; + }, [flushPendingSnapshot]); +}