refactor(canvas): debounce and extract local snapshot persistence

This commit is contained in:
2026-04-03 19:10:07 +02:00
parent 234da6f7d7
commit 7e1a77c38c
2 changed files with 113 additions and 16 deletions

View File

@@ -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<RFNode[]>(nodes);
nodesRef.current = nodes;
const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false);
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
@@ -1919,19 +1917,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
>(null);
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
useEffect(() => {
const snapshot = readCanvasSnapshot<RFNode, RFEdge>(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<RFNode, RFEdge>({
canvasId: canvasId as string,
nodes,
edges,
setNodes,
setEdges,
});
const handleNavToolChange = useCallback((tool: CanvasNavTool) => {
if (tool === "scissor") {

View File

@@ -0,0 +1,105 @@
import {
useCallback,
useEffect,
useRef,
type Dispatch,
type SetStateAction,
} from "react";
import {
readCanvasSnapshot,
writeCanvasSnapshot,
} from "@/lib/canvas-local-persistence";
type UseCanvasLocalSnapshotPersistenceParams<TNode, TEdge> = {
canvasId: string;
nodes: TNode[];
edges: TEdge[];
setNodes: Dispatch<SetStateAction<TNode[]>>;
setEdges: Dispatch<SetStateAction<TEdge[]>>;
debounceMs?: number;
};
type PendingSnapshot<TNode, TEdge> = {
canvasId: string;
nodes: TNode[];
edges: TEdge[];
};
const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 350;
export function useCanvasLocalSnapshotPersistence<TNode, TEdge>({
canvasId,
nodes,
edges,
setNodes,
setEdges,
debounceMs = DEFAULT_SNAPSHOT_DEBOUNCE_MS,
}: UseCanvasLocalSnapshotPersistenceParams<TNode, TEdge>): void {
const hasHydratedLocalSnapshotRef = useRef(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSnapshotRef = useRef<PendingSnapshot<TNode, TEdge> | 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<TNode, TEdge>(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]);
}