diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index bf2ffd8..85df13c 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -35,6 +35,12 @@ import { cn } from "@/lib/utils"; import "@xyflow/react/dist/style.css"; import { toast } from "@/lib/toast"; import { msg, type CanvasNodeDeleteBlockReason } from "@/lib/toast-messages"; +import { + enqueueCanvasOp, + readCanvasSnapshot, + resolveCanvasOp, + writeCanvasSnapshot, +} from "@/lib/canvas-local-persistence"; import { useConvexAuth, useMutation, useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; @@ -82,6 +88,13 @@ interface CanvasInnerProps { const OPTIMISTIC_NODE_PREFIX = "optimistic_"; const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; +function createCanvasOpId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `op_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; +} + /** @xyflow/react default minZoom ist 0.5 — dreimal weiter raus für große Boards. */ const CANVAS_MIN_ZOOM = 0.5 / 3; @@ -118,50 +131,10 @@ function rfEdgeConnectionSignature(edge: RFEdge): string { return `${edge.source}|${edge.target}|${sanitizeHandleForEdgeSignature(edge.sourceHandle)}|${sanitizeHandleForEdgeSignature(edge.targetHandle)}`; } -function isNodeGeometrySyncedWithConvex( - node: RFNode, - doc: Doc<"nodes">, -): boolean { - const styleW = node.style?.width; - const styleH = node.style?.height; - const w = typeof styleW === "number" ? styleW : doc.width; - const h = typeof styleH === "number" ? styleH : doc.height; - return ( - node.position.x === doc.positionX && - node.position.y === doc.positionY && - w === doc.width && - h === doc.height - ); -} - -/** Für Delete-Guard: ausreichend sync, wenn Löschen in Convex sicher ist (kein laufendes Move/Resize). */ -function isNodeDeleteGeometryAcceptable( - node: RFNode, - doc: Doc<"nodes">, -): boolean { - if (isNodeGeometrySyncedWithConvex(node, doc)) return true; - const posEq = - node.position.x === doc.positionX && - node.position.y === doc.positionY; - if (!posEq) return false; - const isMedia = - node.type === "asset" || - node.type === "image" || - node.type === "ai-image"; - // mergeNodesPreservingLocalState: ausgewählte Media-Nodes behalten oft Platzhalter-Maße in style, - // während Convex bereits echte Breite/Höhe hat — Position ist mit dem Server abgeglichen, Löschen ist ok. - if (isMedia && Boolean(node.selected)) return true; - return false; -} - function getNodeDeleteBlockReason( node: RFNode, - convexById: Map>, ): CanvasNodeDeleteBlockReason | null { if (isOptimisticNodeId(node.id)) return "optimistic"; - const doc = convexById.get(node.id); - if (!doc) return "missingInConvex"; - if (!isNodeDeleteGeometryAcceptable(node, doc)) return "geometryPending"; return null; } @@ -846,6 +819,84 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, ); + const runMoveNodeMutation = useCallback( + async (args: { nodeId: Id<"nodes">; positionX: number; positionY: number }) => { + const opId = createCanvasOpId(); + enqueueCanvasOp(canvasId, { id: opId, type: "moveNode", payload: args }); + try { + return await moveNode(args); + } finally { + resolveCanvasOp(canvasId, opId); + } + }, + [canvasId, moveNode], + ); + + const runBatchMoveNodesMutation = useCallback( + async (args: Parameters[0]) => { + const opId = createCanvasOpId(); + enqueueCanvasOp(canvasId, { id: opId, type: "batchMoveNodes", payload: args }); + try { + return await batchMoveNodes(args); + } finally { + resolveCanvasOp(canvasId, opId); + } + }, + [batchMoveNodes, canvasId], + ); + + const runResizeNodeMutation = useCallback( + async (args: { nodeId: Id<"nodes">; width: number; height: number }) => { + const opId = createCanvasOpId(); + enqueueCanvasOp(canvasId, { id: opId, type: "resizeNode", payload: args }); + try { + return await resizeNode(args); + } finally { + resolveCanvasOp(canvasId, opId); + } + }, + [canvasId, resizeNode], + ); + + const runBatchRemoveNodesMutation = useCallback( + async (args: Parameters[0]) => { + const opId = createCanvasOpId(); + enqueueCanvasOp(canvasId, { id: opId, type: "batchRemoveNodes", payload: args }); + try { + return await batchRemoveNodes(args); + } finally { + resolveCanvasOp(canvasId, opId); + } + }, + [batchRemoveNodes, canvasId], + ); + + const runCreateEdgeMutation = useCallback( + async (args: Parameters[0]) => { + const opId = createCanvasOpId(); + enqueueCanvasOp(canvasId, { id: opId, type: "createEdge", payload: args }); + try { + return await createEdge(args); + } finally { + resolveCanvasOp(canvasId, opId); + } + }, + [canvasId, createEdge], + ); + + const runRemoveEdgeMutation = useCallback( + async (args: Parameters[0]) => { + const opId = createCanvasOpId(); + enqueueCanvasOp(canvasId, { id: opId, type: "removeEdge", payload: args }); + try { + return await removeEdge(args); + } finally { + resolveCanvasOp(canvasId, opId); + } + }, + [canvasId, removeEdge], + ); + const splitEdgeAtExistingNodeMut = useMutation( api.nodes.splitEdgeAtExistingNode, ).withOptimisticUpdate((localStore, args) => { @@ -976,7 +1027,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { y: pendingMove.positionY, }, ); - await moveNode({ + await runMoveNodeMutation({ nodeId: realId, positionX: pendingMove.positionX, positionY: pendingMove.positionY, @@ -1022,19 +1073,20 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { x: p.positionX, y: p.positionY, }); - await moveNode({ + await runMoveNodeMutation({ nodeId: r, positionX: p.positionX, positionY: p.positionY, }); } }, - [canvasId, moveNode, splitEdgeAtExistingNodeMut], + [canvasId, runMoveNodeMutation, splitEdgeAtExistingNodeMut], ); // ─── Lokaler State (für flüssiges Dragging) ─────────────────── const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); + const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false); /** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */ const [edgeSyncNonce, setEdgeSyncNonce] = useState(0); const [connectionDropMenu, setConnectionDropMenu] = @@ -1048,6 +1100,20 @@ 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]); + const handleNavToolChange = useCallback((tool: CanvasNavTool) => { if (tool === "scissor") { setScissorsMode(true); @@ -1646,7 +1712,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { void nextNode; if (change.resizing !== false) continue; - void resizeNode({ + void runResizeNodeMutation({ nodeId: change.id as Id<"nodes">, width: change.dimensions.width, height: change.dimensions.height, @@ -1660,7 +1726,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return nextNodes; }); }, - [resizeNode], + [runResizeNodeMutation], ); const onEdgesChange = useCallback((changes: EdgeChange[]) => { @@ -1700,7 +1766,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return; } - void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => { + void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch((error) => { console.error("[Canvas edge remove failed] reconnect end", { edgeId: edge.id, edgeClassName: edge.className ?? null, @@ -1715,7 +1781,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { isReconnectDragActiveRef.current = false; } }, - [removeEdge], + [runRemoveEdgeMutation], ); const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => { @@ -1863,7 +1929,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id)); if (realMoves.length > 0) { - await batchMoveNodes({ + await runBatchMoveNodesMutation({ moves: realMoves.map((n) => ({ nodeId: n.id as Id<"nodes">, positionX: n.position.x, @@ -1922,7 +1988,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); await syncPendingMoveForClientRequest(cidSingle); } else { - await moveNode({ + await runMoveNodeMutation({ nodeId: node.id as Id<"nodes">, positionX: node.position.x, positionY: node.position.y, @@ -2000,10 +2066,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { })(); }, [ - batchMoveNodes, canvasId, edges, - moveNode, + runBatchMoveNodesMutation, + runMoveNodeMutation, setHighlightedIntersectionEdge, splitEdgeAtExistingNodeMut, syncPendingMoveForClientRequest, @@ -2014,7 +2080,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const onConnect = useCallback( (connection: Connection) => { if (connection.source && connection.target) { - createEdge({ + void runCreateEdgeMutation({ canvasId, sourceNodeId: connection.source as Id<"nodes">, targetNodeId: connection.target as Id<"nodes">, @@ -2023,7 +2089,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); } }, - [createEdge, canvasId], + [canvasId, runCreateEdgeMutation], ); const onConnectEnd = useCallback( @@ -2151,15 +2217,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return true; } - const convexById = new Map>( - (convexNodes ?? []).map((n) => [n._id as string, n]), - ); - const allowed: RFNode[] = []; const blocked: RFNode[] = []; const blockedReasons = new Set(); for (const node of matchingNodes) { - const reason = getNodeDeleteBlockReason(node, convexById); + const reason = getNodeDeleteBlockReason(node); if (reason !== null) { blocked.push(node); blockedReasons.add(reason); @@ -2188,7 +2250,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return true; }, - [convexNodes], + [], ); // ─── Node löschen → Convex ──────────────────────────────────── @@ -2214,7 +2276,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { edges, ); const edgePromises = bridgeCreates.map((b) => - createEdge({ + runCreateEdgeMutation({ canvasId, sourceNodeId: b.sourceNodeId, targetNodeId: b.targetNodeId, @@ -2225,7 +2287,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // Batch-Delete + Auto-Reconnect parallel, dann deletingNodeIds aufräumen void Promise.all([ - batchRemoveNodes({ + runBatchRemoveNodesMutation({ nodeIds: idsToDelete as Id<"nodes">[], }), ...edgePromises, @@ -2248,7 +2310,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { toast.info(title); } }, - [nodes, edges, batchRemoveNodes, createEdge, canvasId], + [nodes, edges, runBatchRemoveNodesMutation, runCreateEdgeMutation, canvasId], ); // ─── Edge löschen → Convex ──────────────────────────────────── @@ -2263,7 +2325,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { continue; } - void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => { + void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch((error) => { console.error("[Canvas edge remove failed] edge delete", { edgeId: edge.id, edgeClassName: edge.className ?? null, @@ -2274,7 +2336,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); } }, - [removeEdge], + [runRemoveEdgeMutation], ); async function getImageDimensions(file: File): Promise<{ width: number; height: number }> { @@ -2469,14 +2531,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { (_event: ReactMouseEvent, edge: RFEdge) => { if (!scissorsModeRef.current) return; if (!isEdgeCuttable(edge)) return; - void removeEdge({ edgeId: edge.id as Id<"edges"> }).catch((error) => { + void runRemoveEdgeMutation({ edgeId: edge.id as Id<"edges"> }).catch((error) => { console.error("[Canvas] scissors edge click remove failed", { edgeId: edge.id, error: String(error), }); }); }, - [removeEdge], + [runRemoveEdgeMutation], ); const onScissorsFlowPointerDownCapture = useCallback( @@ -2522,7 +2584,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setScissorStrokePreview(null); if (!scissorsModeRef.current) return; for (const id of strokeIds) { - void removeEdge({ edgeId: id as Id<"edges"> }).catch((error) => { + void runRemoveEdgeMutation({ edgeId: id as Id<"edges"> }).catch((error) => { console.error("[Canvas] scissors stroke remove failed", { edgeId: id, error: String(error), @@ -2536,7 +2598,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { window.addEventListener("pointercancel", handleUp); event.preventDefault(); }, - [removeEdge], + [runRemoveEdgeMutation], ); // ─── Loading State ──────────────────────────────────────────── diff --git a/lib/canvas-local-persistence.ts b/lib/canvas-local-persistence.ts new file mode 100644 index 0000000..8810e56 --- /dev/null +++ b/lib/canvas-local-persistence.ts @@ -0,0 +1,167 @@ +const STORAGE_NAMESPACE = "lemonspace.canvas"; +const SNAPSHOT_VERSION = 1; +const OPS_VERSION = 1; + +type JsonRecord = Record; + +type CanvasSnapshotPayload = { + version: number; + updatedAt: number; + nodes: TNode[]; + edges: TEdge[]; +}; + +type CanvasOpQueuePayload = { + version: number; + updatedAt: number; + ops: CanvasPendingOp[]; +}; + +export type CanvasPendingOp = { + id: string; + type: string; + payload?: unknown; + enqueuedAt: number; +}; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null; +} + +function getLocalStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +function safeParse(raw: string | null): unknown { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function snapshotKey(canvasId: string): string { + return `${STORAGE_NAMESPACE}:snapshot:v${SNAPSHOT_VERSION}:${canvasId}`; +} + +function opsKey(canvasId: string): string { + return `${STORAGE_NAMESPACE}:ops:v${OPS_VERSION}:${canvasId}`; +} + +function readSnapshotPayload( + canvasId: string, +): CanvasSnapshotPayload | null { + const storage = getLocalStorage(); + if (!storage) return null; + const parsed = safeParse(storage.getItem(snapshotKey(canvasId))); + if (!isRecord(parsed)) return null; + const version = parsed.version; + const nodes = parsed.nodes; + const edges = parsed.edges; + if (version !== SNAPSHOT_VERSION) return null; + if (!Array.isArray(nodes) || !Array.isArray(edges)) return null; + return { + version: SNAPSHOT_VERSION, + updatedAt: + typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), + nodes: nodes as TNode[], + edges: edges as TEdge[], + }; +} + +function readOpsPayload(canvasId: string): CanvasOpQueuePayload { + const fallback: CanvasOpQueuePayload = { + version: OPS_VERSION, + updatedAt: Date.now(), + ops: [], + }; + const storage = getLocalStorage(); + if (!storage) return fallback; + const parsed = safeParse(storage.getItem(opsKey(canvasId))); + if (!isRecord(parsed)) return fallback; + if (parsed.version !== OPS_VERSION || !Array.isArray(parsed.ops)) return fallback; + + const ops = parsed.ops + .filter((op): op is JsonRecord => isRecord(op)) + .filter( + (op) => + typeof op.id === "string" && + op.id.length > 0 && + typeof op.type === "string" && + op.type.length > 0, + ) + .map((op) => ({ + id: op.id as string, + type: op.type as string, + payload: op.payload, + enqueuedAt: + typeof op.enqueuedAt === "number" ? op.enqueuedAt : Date.now(), + })); + + return { + version: OPS_VERSION, + updatedAt: + typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), + ops, + }; +} + +function writePayload(key: string, value: unknown): void { + const storage = getLocalStorage(); + if (!storage) return; + try { + storage.setItem(key, JSON.stringify(value)); + } catch { + // Ignore quota/storage write failures in UX cache layer. + } +} + +export function readCanvasSnapshot( + canvasId: string, +): { nodes: TNode[]; edges: TEdge[] } | null { + const parsed = readSnapshotPayload(canvasId); + if (!parsed) return null; + return { nodes: parsed.nodes, edges: parsed.edges }; +} + +export function writeCanvasSnapshot( + canvasId: string, + snapshot: { nodes: TNode[]; edges: TEdge[] }, +): void { + writePayload(snapshotKey(canvasId), { + version: SNAPSHOT_VERSION, + updatedAt: Date.now(), + nodes: snapshot.nodes, + edges: snapshot.edges, + }); +} + +export function enqueueCanvasOp( + canvasId: string, + op: Omit & { enqueuedAt?: number }, +): string { + const entry: CanvasPendingOp = { + ...op, + enqueuedAt: op.enqueuedAt ?? Date.now(), + }; + const payload = readOpsPayload(canvasId); + payload.ops.push(entry); + payload.updatedAt = Date.now(); + writePayload(opsKey(canvasId), payload); + return entry.id; +} + +export function resolveCanvasOp(canvasId: string, opId: string): void { + const payload = readOpsPayload(canvasId); + const nextOps = payload.ops.filter((op) => op.id !== opId); + if (nextOps.length === payload.ops.length) return; + payload.ops = nextOps; + payload.updatedAt = Date.now(); + writePayload(opsKey(canvasId), payload); +} diff --git a/lib/toast-messages.ts b/lib/toast-messages.ts index 017e1eb..77bd8e1 100644 --- a/lib/toast-messages.ts +++ b/lib/toast-messages.ts @@ -1,11 +1,8 @@ // Zentrales Dictionary für alle Toast-Strings. // Spätere i18n: diese Datei gegen Framework-Lookup ersetzen. -/** Grund, warum ein Node-Löschen bis zur Convex-Synchronisierung blockiert ist. */ -export type CanvasNodeDeleteBlockReason = - | "optimistic" - | "missingInConvex" - | "geometryPending"; +/** Grund, warum ein Node-Löschen noch blockiert ist. */ +export type CanvasNodeDeleteBlockReason = "optimistic"; function canvasNodeDeleteWhy( reasons: Set, @@ -24,20 +21,14 @@ function canvasNodeDeleteWhy( desc: "Dieses Element ist noch nicht vollständig auf dem Server gespeichert. Sobald die Synchronisierung fertig ist, kannst du es löschen.", }; } - if (only === "missingInConvex") { - return { - title: "Element noch nicht verfügbar", - desc: "Dieses Element ist in der Datenbank noch nicht sichtbar. Warte einen Moment und versuche das Löschen erneut.", - }; - } return { - title: "Änderungen werden gespeichert", - desc: "Position oder Größe ist noch nicht mit dem Server abgeglichen — zum Beispiel direkt nach Verschieben oder nach dem Ziehen an der Größe. Bitte kurz warten.", + title: "Löschen momentan nicht möglich", + desc: "Bitte kurz warten und erneut versuchen.", }; } return { title: "Löschen momentan nicht möglich", - desc: "Mindestens ein Element wird noch angelegt, oder Position bzw. Größe wird noch gespeichert. Bitte kurz warten und erneut versuchen.", + desc: "Mindestens ein Element wird noch angelegt. Bitte kurz warten und erneut versuchen.", }; }