From 32bd188d898e0e298bfd17fa055f5ed4a8b905d2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 1 Apr 2026 09:40:31 +0200 Subject: [PATCH] Implement local-first canvas sync and fix drag edge stability --- app/CLAUDE.md | 2 +- components/canvas/CLAUDE.md | 36 +- components/canvas/asset-browser-panel.tsx | 20 +- components/canvas/canvas-delete-handlers.ts | 12 +- .../canvas/canvas-placement-context.tsx | 143 ++--- components/canvas/canvas-sync-context.tsx | 43 ++ components/canvas/canvas.tsx | 560 ++++++++++++++---- components/canvas/nodes/ai-image-node.tsx | 11 +- components/canvas/nodes/asset-node.tsx | 9 +- components/canvas/nodes/frame-node.tsx | 16 +- components/canvas/nodes/group-node.tsx | 9 +- components/canvas/nodes/image-node.tsx | 23 +- components/canvas/nodes/note-node.tsx | 7 +- components/canvas/nodes/prompt-node.tsx | 15 +- components/canvas/nodes/text-node.tsx | 7 +- components/canvas/nodes/video-node.tsx | 14 +- components/canvas/video-browser-panel.tsx | 19 +- lib/canvas-local-persistence.ts | 12 + lib/canvas-op-queue.ts | 420 +++++++++++++ 19 files changed, 1095 insertions(+), 283 deletions(-) create mode 100644 components/canvas/canvas-sync-context.tsx create mode 100644 lib/canvas-op-queue.ts diff --git a/app/CLAUDE.md b/app/CLAUDE.md index 67700b7..668e915 100644 --- a/app/CLAUDE.md +++ b/app/CLAUDE.md @@ -13,7 +13,7 @@ app/ ├── globals.css ← Tailwind v4 + Design-Tokens ├── (app)/ ← Authentifizierte App-Routen │ ├── canvas/[canvasId]/ ← Canvas-Editor -│ │ └── page.tsx +│ │ └── page.tsx ← SSR-Auth/ID-Validation, rendert dann `CanvasShell` │ └── settings/ │ └── billing/ ← Billing-Einstellungen ├── auth/ ← Auth-Routen (Better Auth) diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index 47464fc..d186fc9 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -8,15 +8,18 @@ Der Canvas ist das Herzstück von LemonSpace. Er basiert auf `@xyflow/react` (Re ``` app/(app)/canvas/[canvasId]/page.tsx - └── ← components/canvas/canvas.tsx - ├── - │ └── ← Haupt-Komponente (~1800 Zeilen) - │ ├── Convex useQuery ← Realtime-Sync - │ ├── nodeTypes Map ← node-types.ts - │ ├── localStorage Cache ← canvas-local-persistence.ts - │ ├── Interaction-Hooks ← canvas-*.ts Helper - │ └── Panel-Komponenten - └── Context Providers + └── ← components/canvas/canvas-shell.tsx + ├── Resizable Sidebar/Main Layout ← shadcn `resizable` + ├── ← collapsible Rail/Fulllayout + └── ← components/canvas/canvas.tsx + ├── + │ └── ← Haupt-Komponente (~1800 Zeilen) + │ ├── Convex useQuery ← Realtime-Sync + │ ├── nodeTypes Map ← node-types.ts + │ ├── localStorage Cache ← canvas-local-persistence.ts + │ ├── Interaction-Hooks ← canvas-*.ts Helper + │ └── Panel-Komponenten + └── Context Providers ``` **`canvas.tsx`** ist weiterhin die zentrale Orchestrierungsdatei. Viel Low-Level-Logik wurde in dedizierte Module ausgelagert, aber Mutations-Flow, Event-Wiring und Render-Composition liegen weiterhin hier. @@ -124,9 +127,10 @@ Compare-Node hat zusätzlich Handle-spezifische Farben (`left` → Blau, `right` | Datei | Zweck | |-------|-------| +| `canvas-shell.tsx` | Client-Layout-Wrapper für Sidebar/Main inkl. Resizing, Auto-Collapse und Rail-Mode-Umschaltung | | `canvas-toolbar.tsx` | Werkzeug-Leiste (Select, Pan, Zoom-Controls) | | `canvas-app-menu.tsx` | App-Menü (Einstellungen, Logout, Canvas-Name) | -| `canvas-sidebar.tsx` | Node-Palette (linke Seite) | +| `canvas-sidebar.tsx` | Node-Palette links; unterstützt Full-Mode und Rail-Mode (icon-only) | | `canvas-command-palette.tsx` | Cmd+K Command Palette | | `canvas-connection-drop-menu.tsx` | Kontext-Menü beim Loslassen einer Verbindung | | `canvas-node-template-picker.tsx` | Node aus Template einfügen | @@ -141,6 +145,18 @@ Compare-Node hat zusätzlich Handle-spezifische Farben (`left` → Blau, `right` --- +## Sidebar Resizing & Rail-Mode + +- Resizing läuft über `react-resizable-panels` via `components/ui/resizable.tsx` in `canvas-shell.tsx`. +- Wichtige Größen werden als **Strings mit Einheit** gesetzt (z. B. `"18%"`, `"40%"`, `"64px"`). In der verwendeten Library-Version werden numerische Werte als Pixel interpretiert. +- Sidebar ist `collapsible`; bei Unterschreiten von `minSize` wird auf `collapsedSize` reduziert. +- Eingeklappt bedeutet nicht „unsichtbar“: `collapsedSize` ist absichtlich > 0 (`64px`), damit ein sichtbarer Rail bleibt. +- `canvas-shell.tsx` schaltet per `onResize` abhängig von der tatsächlichen Pixelbreite zwischen Full-Mode und Rail-Mode um (`railMode` Prop an `CanvasSidebar`). +- `CanvasUserMenu` unterstützt ebenfalls einen kompakten Rail-Mode über `compact`. +- Scroll-Chaining ist begrenzt (`overscroll-contain` in der Sidebar-Scrollfläche + `overscroll-none` am Shell-Root), um visuelle Artefakte beim Scrollen am Ende zu verhindern. + +--- + ## Wichtige Gotchas - **`data.url` vs `storageId`:** Node-Komponenten erhalten `data.url` (aufgelöste HTTP-URL), nicht `storageId` direkt. Die URL wird von `convexNodeDocWithMergedStorageUrl` injiziert. Bei neuen Node-Typen mit Bild immer diesen Flow prüfen. diff --git a/components/canvas/asset-browser-panel.tsx b/components/canvas/asset-browser-panel.tsx index fa2a1c1..eb41f86 100644 --- a/components/canvas/asset-browser-panel.tsx +++ b/components/canvas/asset-browser-panel.tsx @@ -10,7 +10,7 @@ import { useState, } from "react"; import { createPortal } from "react-dom"; -import { useAction, useMutation } from "convex/react"; +import { useAction } from "convex/react"; import { X, Search, Loader2, AlertCircle } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -19,6 +19,8 @@ import { Button } from "@/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; +import { toast } from "@/lib/toast"; type AssetType = "photo" | "vector" | "icon"; @@ -88,8 +90,7 @@ export function AssetBrowserPanel({ const [selectingAssetKey, setSelectingAssetKey] = useState(null); const searchFreepik = useAction(api.freepik.search); - const updateData = useMutation(api.nodes.updateData); - const resizeNode = useMutation(api.nodes.resize); + const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length)); const requestSequenceRef = useRef(0); const scrollAreaRef = useRef(null); @@ -187,10 +188,17 @@ export function AssetBrowserPanel({ const handleSelect = useCallback( async (asset: FreepikResult) => { if (isSelecting) return; + if (status.isOffline) { + toast.warning( + "Offline aktuell nicht unterstützt", + "Asset-Auswahl benötigt eine aktive Verbindung.", + ); + return; + } const assetKey = `${asset.assetType}-${asset.id}`; setSelectingAssetKey(assetKey); try { - await updateData({ + await queueNodeDataUpdate({ nodeId: nodeId as Id<"nodes">, data: { assetId: asset.id, @@ -214,7 +222,7 @@ export function AssetBrowserPanel({ orientation: asset.orientation, }); - await resizeNode({ + await queueNodeResize({ nodeId: nodeId as Id<"nodes">, width: targetSize.width, height: targetSize.height, @@ -226,7 +234,7 @@ export function AssetBrowserPanel({ setSelectingAssetKey(null); } }, - [canvasId, isSelecting, nodeId, onClose, resizeNode, updateData], + [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], ); const handlePreviousPage = useCallback(() => { diff --git a/components/canvas/canvas-delete-handlers.ts b/components/canvas/canvas-delete-handlers.ts index fba9802..da68c5d 100644 --- a/components/canvas/canvas-delete-handlers.ts +++ b/components/canvas/canvas-delete-handlers.ts @@ -16,6 +16,7 @@ import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers"; type UseCanvasDeleteHandlersParams = { canvasId: Id<"canvases">; + isOffline: boolean; nodes: RFNode[]; edges: RFEdge[]; deletingNodeIds: MutableRefObject>; @@ -33,6 +34,7 @@ type UseCanvasDeleteHandlersParams = { export function useCanvasDeleteHandlers({ canvasId, + isOffline, nodes, edges, deletingNodeIds, @@ -53,6 +55,14 @@ export function useCanvasDeleteHandlers({ nodes: RFNode[]; edges: RFEdge[]; }) => { + if (isOffline && (matchingNodes.length > 0 || matchingEdges.length > 0)) { + toast.warning( + "Offline aktuell nicht unterstützt", + "Löschen ist in Stufe 1 nur online verfügbar.", + ); + return false; + } + if (matchingNodes.length === 0) { return true; } @@ -90,7 +100,7 @@ export function useCanvasDeleteHandlers({ return true; }, - [], + [isOffline], ); const onNodesDelete = useCallback( diff --git a/components/canvas/canvas-placement-context.tsx b/components/canvas/canvas-placement-context.tsx index b156260..e94fb1e 100644 --- a/components/canvas/canvas-placement-context.tsx +++ b/components/canvas/canvas-placement-context.tsx @@ -7,102 +7,63 @@ import { useMemo, type ReactNode, } from "react"; -import type { ReactMutation } from "convex/react"; -import type { FunctionReference } from "convex/server"; import { useStore, type Edge as RFEdge } from "@xyflow/react"; import type { Id } from "@/convex/_generated/dataModel"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; -type CreateNodeMutation = ReactMutation< - FunctionReference< - "mutation", - "public", - { - canvasId: Id<"canvases">; - type: string; - positionX: number; - positionY: number; - width: number; - height: number; - data: unknown; - parentId?: Id<"nodes">; - zIndex?: number; - clientRequestId?: string; - }, - Id<"nodes"> - > ->; +type CreateNodeArgs = { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: unknown; + parentId?: Id<"nodes">; + zIndex?: number; + clientRequestId?: string; +}; -type CreateNodeWithEdgeSplitMutation = ReactMutation< - FunctionReference< - "mutation", - "public", - { - canvasId: Id<"canvases">; - type: string; - positionX: number; - positionY: number; - width: number; - height: number; - data: unknown; - parentId?: Id<"nodes">; - zIndex?: number; - splitEdgeId: Id<"edges">; - newNodeTargetHandle?: string; - newNodeSourceHandle?: string; - splitSourceHandle?: string; - splitTargetHandle?: string; - }, - Id<"nodes"> - > ->; +type CreateNodeWithEdgeSplitArgs = { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: unknown; + parentId?: Id<"nodes">; + zIndex?: number; + splitEdgeId: Id<"edges">; + newNodeTargetHandle?: string; + newNodeSourceHandle?: string; + splitSourceHandle?: string; + splitTargetHandle?: string; +}; -type CreateNodeWithEdgeFromSourceMutation = ReactMutation< - FunctionReference< - "mutation", - "public", - { - canvasId: Id<"canvases">; - type: string; - positionX: number; - positionY: number; - width: number; - height: number; - data: unknown; - parentId?: Id<"nodes">; - zIndex?: number; - clientRequestId?: string; - sourceNodeId: Id<"nodes">; - sourceHandle?: string; - targetHandle?: string; - }, - Id<"nodes"> - > ->; +type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & { + sourceNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; +}; -type CreateNodeWithEdgeToTargetMutation = ReactMutation< - FunctionReference< - "mutation", - "public", - { - canvasId: Id<"canvases">; - type: string; - positionX: number; - positionY: number; - width: number; - height: number; - data: unknown; - parentId?: Id<"nodes">; - zIndex?: number; - clientRequestId?: string; - targetNodeId: Id<"nodes">; - sourceHandle?: string; - targetHandle?: string; - }, - Id<"nodes"> - > ->; +type CreateNodeWithEdgeToTargetArgs = CreateNodeArgs & { + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; +}; + +type CreateNodeMutation = (args: CreateNodeArgs) => Promise>; +type CreateNodeWithEdgeSplitMutation = ( + args: CreateNodeWithEdgeSplitArgs, +) => Promise>; +type CreateNodeWithEdgeFromSourceMutation = ( + args: CreateNodeWithEdgeFromSourceArgs, +) => Promise>; +type CreateNodeWithEdgeToTargetMutation = ( + args: CreateNodeWithEdgeToTargetArgs, +) => Promise>; type FlowPoint = { x: number; y: number }; @@ -296,6 +257,12 @@ export function CanvasPlacementProvider({ notifySettled(realId); return realId; } catch (error) { + if ( + error instanceof Error && + error.message === "offline-unsupported" + ) { + throw error; + } console.error("[Canvas placement] edge split failed", { edgeId: hitEdge.id, type, diff --git a/components/canvas/canvas-sync-context.tsx b/components/canvas/canvas-sync-context.tsx new file mode 100644 index 0000000..430a833 --- /dev/null +++ b/components/canvas/canvas-sync-context.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { ReactNode } from "react"; +import type { Id } from "@/convex/_generated/dataModel"; + +type CanvasSyncStatus = { + pendingCount: number; + isSyncing: boolean; + isOffline: boolean; +}; + +type CanvasSyncContextValue = { + queueNodeDataUpdate: (args: { nodeId: Id<"nodes">; data: unknown }) => Promise; + queueNodeResize: (args: { + nodeId: Id<"nodes">; + width: number; + height: number; + }) => Promise; + status: CanvasSyncStatus; +}; + +const CanvasSyncContext = createContext(null); + +export function CanvasSyncProvider({ + value, + children, +}: { + value: CanvasSyncContextValue; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useCanvasSync(): CanvasSyncContextValue { + const context = useContext(CanvasSyncContext); + if (!context) { + throw new Error("useCanvasSync must be used within CanvasSyncProvider"); + } + return context; +} diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index af2da83..3c0d86f 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -35,10 +35,25 @@ import { enqueueCanvasOp, readCanvasSnapshot, resolveCanvasOp, + resolveCanvasOps, writeCanvasSnapshot, } from "@/lib/canvas-local-persistence"; +import { + ackCanvasSyncOp, + type CanvasSyncOpPayloadByType, + countCanvasSyncOps, + dropExpiredCanvasSyncOps, + enqueueCanvasSyncOp, + listCanvasSyncOps, + markCanvasSyncOpFailed, +} from "@/lib/canvas-op-queue"; -import { useConvexAuth, useMutation, useQuery } from "convex/react"; +import { + useConvexAuth, + useConvexConnectionState, + useMutation, + useQuery, +} from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Doc, Id } from "@/convex/_generated/dataModel"; import { authClient } from "@/lib/auth-client"; @@ -103,11 +118,31 @@ import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; import { getImageDimensions } from "./canvas-media-utils"; import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasScissors } from "./canvas-scissors"; +import { CanvasSyncProvider } from "./canvas-sync-context"; interface CanvasInnerProps { canvasId: Id<"canvases">; } +function getErrorMessage(error: unknown): string { + if (error instanceof Error && typeof error.message === "string") { + return error.message; + } + return String(error); +} + +function isLikelyTransientSyncError(error: unknown): boolean { + const message = getErrorMessage(error).toLowerCase(); + return ( + message.includes("network") || + message.includes("websocket") || + message.includes("fetch") || + message.includes("timeout") || + message.includes("temporarily") || + message.includes("connection") + ); +} + function CanvasInner({ canvasId }: CanvasInnerProps) { const { screenToFlowPosition } = useReactFlow(); const { resolvedTheme } = useTheme(); @@ -176,8 +211,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ── const moveNode = useMutation(api.nodes.move); const resizeNode = useMutation(api.nodes.resize); + const updateNodeData = useMutation(api.nodes.updateData); const generateUploadUrl = useMutation(api.storage.generateUploadUrl); - const batchMoveNodes = useMutation(api.nodes.batchMove); + const connectionState = useConvexConnectionState(); const pendingMoveAfterCreateRef = useRef( new Map(), ); @@ -357,17 +393,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const edgeList = localStore.getQuery(api.edges.list, { canvasId }); if (nodeList === undefined || edgeList === undefined) return; - const removeSet = new Set(args.nodeIds.map((id) => id as string)); + const removeSet = new Set( + args.nodeIds.map((id: Id<"nodes">) => id as string), + ); localStore.setQuery( api.nodes.list, { canvasId }, - nodeList.filter((n) => !removeSet.has(n._id)), + nodeList.filter((n: Doc<"nodes">) => !removeSet.has(n._id)), ); localStore.setQuery( api.edges.list, { canvasId }, edgeList.filter( - (e) => + (e: Doc<"edges">) => !removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId), ), ); @@ -406,87 +444,279 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { localStore.setQuery( api.edges.list, { canvasId }, - edgeList.filter((e) => e._id !== args.edgeId), + edgeList.filter((e: Doc<"edges">) => e._id !== args.edgeId), ); }, ); + const [pendingSyncCount, setPendingSyncCount] = useState(0); + const [isSyncing, setIsSyncing] = useState(false); + const [isBrowserOnline, setIsBrowserOnline] = useState( + typeof navigator === "undefined" ? true : navigator.onLine, + ); + const syncInFlightRef = useRef(false); + const lastOfflineUnsupportedToastAtRef = useRef(0); + + const isSyncOnline = + isBrowserOnline === true && connectionState.isWebSocketConnected === true; + + useEffect(() => { + const handleOnline = () => setIsBrowserOnline(true); + const handleOffline = () => setIsBrowserOnline(false); + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + const notifyOfflineUnsupported = useCallback((label: string) => { + const now = Date.now(); + if (now - lastOfflineUnsupportedToastAtRef.current < 1500) return; + lastOfflineUnsupportedToastAtRef.current = now; + toast.warning( + "Offline aktuell nicht unterstützt", + `${label} ist in Stufe 1 nur online verfügbar.`, + ); + }, []); + + const runCreateNodeOnlineOnly = useCallback( + async (args: Parameters[0]) => { + if (!isSyncOnline) { + notifyOfflineUnsupported("Node erstellen"); + throw new Error("offline-unsupported"); + } + return await createNode(args); + }, + [createNode, isSyncOnline, notifyOfflineUnsupported], + ); + + const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback( + async (args: Parameters[0]) => { + if (!isSyncOnline) { + notifyOfflineUnsupported("Node mit Verbindung erstellen"); + throw new Error("offline-unsupported"); + } + return await createNodeWithEdgeFromSource(args); + }, + [createNodeWithEdgeFromSource, isSyncOnline, notifyOfflineUnsupported], + ); + + const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback( + async (args: Parameters[0]) => { + if (!isSyncOnline) { + notifyOfflineUnsupported("Node mit Verbindung erstellen"); + throw new Error("offline-unsupported"); + } + return await createNodeWithEdgeToTarget(args); + }, + [createNodeWithEdgeToTarget, isSyncOnline, notifyOfflineUnsupported], + ); + + const runCreateNodeWithEdgeSplitOnlineOnly = useCallback( + async (args: Parameters[0]) => { + if (!isSyncOnline) { + notifyOfflineUnsupported("Kanten-Split"); + throw new Error("offline-unsupported"); + } + return await createNodeWithEdgeSplit(args); + }, + [createNodeWithEdgeSplit, isSyncOnline, notifyOfflineUnsupported], + ); + + const refreshPendingSyncCount = useCallback(async () => { + const count = await countCanvasSyncOps(canvasId as string); + setPendingSyncCount(count); + }, [canvasId]); + + const flushCanvasSyncQueue = useCallback(async () => { + if (!isSyncOnline) return; + if (syncInFlightRef.current) return; + syncInFlightRef.current = true; + setIsSyncing(true); + + try { + const now = Date.now(); + const expiredIds = await dropExpiredCanvasSyncOps(canvasId as string, now); + if (expiredIds.length > 0) { + resolveCanvasOps(canvasId as string, expiredIds); + toast.info( + "Lokale Änderungen verworfen", + `${expiredIds.length} ältere Offline-Änderungen (älter als 24h) wurden entfernt.`, + ); + } + + const queue = await listCanvasSyncOps(canvasId as string); + let permanentFailures = 0; + + for (const op of queue) { + if (op.expiresAt <= now) continue; + if (op.nextRetryAt > now) continue; + + try { + if (op.type === "moveNode") { + await moveNode(op.payload); + } else if (op.type === "resizeNode") { + await resizeNode(op.payload); + } else if (op.type === "updateData") { + await updateNodeData(op.payload); + } + + await ackCanvasSyncOp(op.id); + resolveCanvasOp(canvasId as string, op.id); + } catch (error: unknown) { + const transient = + !isSyncOnline || isLikelyTransientSyncError(error); + if (transient) { + const backoffMs = Math.min(30_000, 1000 * 2 ** Math.min(op.attemptCount, 5)); + await markCanvasSyncOpFailed(op.id, { + nextRetryAt: Date.now() + backoffMs, + lastError: getErrorMessage(error), + }); + break; + } + + permanentFailures += 1; + await ackCanvasSyncOp(op.id); + resolveCanvasOp(canvasId as string, op.id); + } + } + + if (permanentFailures > 0) { + toast.warning( + "Einige Änderungen konnten nicht synchronisiert werden", + `${permanentFailures} lokale Änderungen wurden übersprungen.`, + ); + } + } finally { + syncInFlightRef.current = false; + setIsSyncing(false); + await refreshPendingSyncCount(); + } + }, [canvasId, isSyncOnline, moveNode, refreshPendingSyncCount, resizeNode, updateNodeData]); + + const enqueueSyncMutation = useCallback( + async ( + type: TType, + payload: CanvasSyncOpPayloadByType[TType], + ) => { + const opId = createCanvasOpId(); + const now = Date.now(); + const result = await enqueueCanvasSyncOp({ + id: opId, + canvasId: canvasId as string, + type, + payload, + now, + }); + enqueueCanvasOp(canvasId as string, { + id: opId, + type, + payload, + enqueuedAt: now, + }); + resolveCanvasOps(canvasId as string, result.replacedIds); + await refreshPendingSyncCount(); + void flushCanvasSyncQueue(); + }, + [canvasId, flushCanvasSyncQueue, refreshPendingSyncCount], + ); + + useEffect(() => { + void refreshPendingSyncCount(); + }, [refreshPendingSyncCount]); + + useEffect(() => { + if (!isSyncOnline) return; + void flushCanvasSyncQueue(); + }, [flushCanvasSyncQueue, isSyncOnline]); + + useEffect(() => { + if (!isSyncOnline || pendingSyncCount <= 0) return; + const interval = window.setInterval(() => { + void flushCanvasSyncQueue(); + }, 5000); + return () => window.clearInterval(interval); + }, [flushCanvasSyncQueue, isSyncOnline, pendingSyncCount]); + + useEffect(() => { + const handleVisibilityOrFocus = () => { + if (!isSyncOnline) return; + void flushCanvasSyncQueue(); + }; + + window.addEventListener("focus", handleVisibilityOrFocus); + document.addEventListener("visibilitychange", handleVisibilityOrFocus); + return () => { + window.removeEventListener("focus", handleVisibilityOrFocus); + document.removeEventListener("visibilitychange", handleVisibilityOrFocus); + }; + }, [flushCanvasSyncQueue, isSyncOnline]); + 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); - } + await enqueueSyncMutation("moveNode", args); }, - [canvasId, moveNode], + [enqueueSyncMutation], ); 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); + async (args: { + moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[]; + }) => { + for (const move of args.moves) { + await enqueueSyncMutation("moveNode", move); } }, - [batchMoveNodes, canvasId], + [enqueueSyncMutation], ); 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); - } + await enqueueSyncMutation("resizeNode", args); }, - [canvasId, resizeNode], + [enqueueSyncMutation], + ); + + const runUpdateNodeDataMutation = useCallback( + async (args: { nodeId: Id<"nodes">; data: unknown }) => { + await enqueueSyncMutation("updateData", args); + }, + [enqueueSyncMutation], ); 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); + if (!isSyncOnline) { + notifyOfflineUnsupported("Löschen"); + return; } + await batchRemoveNodes(args); }, - [batchRemoveNodes, canvasId], + [batchRemoveNodes, isSyncOnline, notifyOfflineUnsupported], ); 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); + if (!isSyncOnline) { + notifyOfflineUnsupported("Kante erstellen"); + return; } + await createEdge(args); }, - [canvasId, createEdge], + [createEdge, isSyncOnline, notifyOfflineUnsupported], ); 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); + if (!isSyncOnline) { + notifyOfflineUnsupported("Kante entfernen"); + return; } + await removeEdge(args); }, - [canvasId, removeEdge], + [isSyncOnline, notifyOfflineUnsupported, removeEdge], ); const splitEdgeAtExistingNodeMut = useMutation( @@ -500,14 +730,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); if (edgeList === undefined || nodeList === undefined) return; - const removed = edgeList.find((e) => e._id === args.splitEdgeId); + const removed = edgeList.find( + (e: Doc<"edges">) => e._id === args.splitEdgeId, + ); if (!removed) return; const t1 = `${OPTIMISTIC_EDGE_PREFIX}s1_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; const t2 = `${OPTIMISTIC_EDGE_PREFIX}s2_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; const now = Date.now(); - const nextEdges = edgeList.filter((e) => e._id !== args.splitEdgeId); + const nextEdges = edgeList.filter( + (e: Doc<"edges">) => e._id !== args.splitEdgeId, + ); nextEdges.push( { _id: t1, @@ -536,7 +770,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { localStore.setQuery( api.nodes.list, { canvasId: args.canvasId }, - nodeList.map((n) => + nodeList.map((n: Doc<"nodes">) => n._id === args.middleNodeId ? { ...n, @@ -549,6 +783,17 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } }); + const runSplitEdgeAtExistingNodeMutation = useCallback( + async (args: Parameters[0]) => { + if (!isSyncOnline) { + notifyOfflineUnsupported("Kanten-Split"); + return; + } + await splitEdgeAtExistingNodeMut(args); + }, + [isSyncOnline, notifyOfflineUnsupported, splitEdgeAtExistingNodeMut], + ); + /** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */ const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< string | null @@ -586,7 +831,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } resolvedRealIdByClientRequestRef.current.delete(clientRequestId); try { - await splitEdgeAtExistingNodeMut({ + await runSplitEdgeAtExistingNodeMutation({ canvasId, splitEdgeId: splitPayload.intersectedEdgeId, middleNodeId: realId, @@ -642,7 +887,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (splitPayload) { pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); try { - await splitEdgeAtExistingNodeMut({ + await runSplitEdgeAtExistingNodeMutation({ canvasId, splitEdgeId: splitPayload.intersectedEdgeId, middleNodeId: r, @@ -672,12 +917,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }); } }, - [canvasId, runMoveNodeMutation, splitEdgeAtExistingNodeMut], + [canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation], ); // ─── Lokaler State (für flüssiges Dragging) ─────────────────── const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); + const nodesRef = useRef(nodes); + nodesRef.current = nodes; const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false); /** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */ const [edgeSyncNonce, setEdgeSyncNonce] = useState(0); @@ -788,6 +1035,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({ canvasId, + isOffline: !isSyncOnline, nodes, edges, deletingNodeIds, @@ -816,7 +1064,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current; const currentConvexIdList = convexNodes !== undefined - ? convexNodes.map((n) => n._id as string) + ? convexNodes.map((n: Doc<"nodes">) => n._id as string) : []; const currentConvexIdSet = new Set(currentConvexIdList); const newlyAppearedIds: string[] = []; @@ -827,10 +1075,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const tempEdges = prev.filter((e) => e.className === "temp"); const sourceTypeByNodeId = convexNodes !== undefined - ? new Map(convexNodes.map((n) => [n._id, n.type])) + ? new Map( + convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type]), + ) : undefined; const glowMode = resolvedTheme === "dark" ? "dark" : "light"; - const mapped = convexEdges.map((edge) => + const mapped = convexEdges.map((edge: Doc<"edges">) => sourceTypeByNodeId ? convexEdgeToRFWithSourceGlow( edge, @@ -843,14 +1093,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); const convexNodeIds = convexNodes !== undefined - ? new Set(convexNodes.map((n) => n._id as string)) + ? new Set(convexNodes.map((n: Doc<"nodes">) => n._id as string)) : null; const realIdByClientRequest = resolvedRealIdByClientRequestRef.current; + const isAnyNodeDragging = + isDragging.current || + nodesRef.current.some((n) => + Boolean((n as { dragging?: boolean }).dragging), + ); + + const localHasOptimisticNode = (nodeId: string): boolean => { + if (!isOptimisticNodeId(nodeId)) return false; + return nodesRef.current.some((n) => n.id === nodeId); + }; const resolveEndpoint = (nodeId: string): string => { if (!isOptimisticNodeId(nodeId)) return nodeId; const cr = clientRequestIdFromOptimisticNodeId(nodeId); if (!cr) return nodeId; + if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) { + return nodeId; + } const real = realIdByClientRequest.get(cr); return real !== undefined ? (real as string) : nodeId; }; @@ -862,6 +1125,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ): string => { const base = resolveEndpoint(nodeId); if (!isOptimisticNodeId(base)) return base; + if (isAnyNodeDragging) return base; const nodeCr = clientRequestIdFromOptimisticNodeId(base); if (nodeCr === null) return base; const edgeCr = clientRequestIdFromOptimisticEdgeId(edge.id); @@ -877,6 +1141,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }; const endpointUsable = (nodeId: string): boolean => { + if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) return true; const resolved = resolveEndpoint(nodeId); if (convexNodeIds?.has(resolved)) return true; if (convexNodeIds?.has(nodeId)) return true; @@ -950,9 +1215,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { if (realId === undefined) continue; const nodePresent = convexNodes !== undefined && - convexNodes.some((n) => n._id === realId); + convexNodes.some((n: Doc<"nodes">) => n._id === realId); const edgeTouchesNewNode = convexEdges.some( - (e) => e.sourceNodeId === realId || e.targetNodeId === realId, + (e: Doc<"edges">) => + e.sourceNodeId === realId || e.targetNodeId === realId, ); if (nodePresent && edgeTouchesNewNode) { pendingConnectionCreatesRef.current.delete(cr); @@ -978,22 +1244,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { Boolean((n as { dragging?: boolean }).dragging), ); if (isDragging.current || anyRfNodeDragging) { - const needsOptimisticHandoff = previousNodes.some((n) => { - const cr = clientRequestIdFromOptimisticNodeId(n.id); - return ( - cr !== null && - resolvedRealIdByClientRequestRef.current.has(cr) - ); - }); - if (!needsOptimisticHandoff) { - return previousNodes; - } + // Kritisch für UX: Kein optimistic->real-ID-Handoff während aktivem Drag. + // Sonst kann React Flow den Drag verlieren ("Node klebt"), sobald der + // Server-Create zurückkommt und die ID im laufenden Pointer-Stream wechselt. + return previousNodes; } const prevDataById = new Map( previousNodes.map((node) => [node.id, node.data as Record]), ); - const enriched = convexNodes.map((node) => + const enriched = convexNodes.map((node: Doc<"nodes">) => convexNodeDocWithMergedStorageUrl( node, storageUrlsById, @@ -1227,9 +1487,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── Drag Stop → Commit zu Convex ───────────────────────────── const onNodeDragStop = useCallback( (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => { + const primaryNode = (node as RFNode | undefined) ?? draggedNodes[0]; const intersectedEdgeId = overlappedEdgeRef.current; void (async () => { + if (!primaryNode) { + overlappedEdgeRef.current = null; + setHighlightedIntersectionEdge(null); + isDragging.current = false; + return; + } try { const intersectedEdge = intersectedEdgeId ? edges.find( @@ -1238,12 +1505,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ) : undefined; - const splitHandles = NODE_HANDLE_MAP[node.type ?? ""]; + const splitHandles = NODE_HANDLE_MAP[primaryNode.type ?? ""]; const splitEligible = intersectedEdge !== undefined && splitHandles !== undefined && - intersectedEdge.source !== node.id && - intersectedEdge.target !== node.id && + intersectedEdge.source !== primaryNode.id && + intersectedEdge.target !== primaryNode.id && hasHandleKey(splitHandles, "source") && hasHandleKey(splitHandles, "target"); @@ -1273,8 +1540,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { return; } - const multiCid = clientRequestIdFromOptimisticNodeId(node.id); - let middleId = node.id as Id<"nodes">; + const multiCid = clientRequestIdFromOptimisticNodeId(primaryNode.id); + let middleId = primaryNode.id as Id<"nodes">; if (multiCid) { const r = resolvedRealIdByClientRequestRef.current.get(multiCid); if (!r) { @@ -1290,15 +1557,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ), middleSourceHandle: normalizeHandle(splitHandles.source), middleTargetHandle: normalizeHandle(splitHandles.target), - positionX: node.position.x, - positionY: node.position.y, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); return; } middleId = r; } - await splitEdgeAtExistingNodeMut({ + await runSplitEdgeAtExistingNodeMutation({ canvasId, splitEdgeId: intersectedEdge.id as Id<"edges">, middleNodeId: middleId, @@ -1311,31 +1578,31 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } if (!splitEligible || !intersectedEdge) { - const cidSingle = clientRequestIdFromOptimisticNodeId(node.id); + const cidSingle = clientRequestIdFromOptimisticNodeId(primaryNode.id); if (cidSingle) { pendingMoveAfterCreateRef.current.set(cidSingle, { - positionX: node.position.x, - positionY: node.position.y, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); await syncPendingMoveForClientRequest(cidSingle); } else { await runMoveNodeMutation({ - nodeId: node.id as Id<"nodes">, - positionX: node.position.x, - positionY: node.position.y, + nodeId: primaryNode.id as Id<"nodes">, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); } return; } - const singleCid = clientRequestIdFromOptimisticNodeId(node.id); + const singleCid = clientRequestIdFromOptimisticNodeId(primaryNode.id); if (singleCid) { const resolvedSingle = resolvedRealIdByClientRequestRef.current.get(singleCid); if (!resolvedSingle) { pendingMoveAfterCreateRef.current.set(singleCid, { - positionX: node.position.x, - positionY: node.position.y, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); pendingEdgeSplitByClientRequestRef.current.set(singleCid, { intersectedEdgeId: intersectedEdge.id as Id<"edges">, @@ -1349,13 +1616,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ), middleSourceHandle: normalizeHandle(splitHandles.source), middleTargetHandle: normalizeHandle(splitHandles.target), - positionX: node.position.x, - positionY: node.position.y, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); await syncPendingMoveForClientRequest(singleCid); return; } - await splitEdgeAtExistingNodeMut({ + await runSplitEdgeAtExistingNodeMutation({ canvasId, splitEdgeId: intersectedEdge.id as Id<"edges">, middleNodeId: resolvedSingle, @@ -1363,29 +1630,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), newNodeSourceHandle: normalizeHandle(splitHandles.source), newNodeTargetHandle: normalizeHandle(splitHandles.target), - positionX: node.position.x, - positionY: node.position.y, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); pendingMoveAfterCreateRef.current.delete(singleCid); return; } - await splitEdgeAtExistingNodeMut({ + await runSplitEdgeAtExistingNodeMutation({ canvasId, splitEdgeId: intersectedEdge.id as Id<"edges">, - middleNodeId: node.id as Id<"nodes">, + middleNodeId: primaryNode.id as Id<"nodes">, splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), newNodeSourceHandle: normalizeHandle(splitHandles.source), newNodeTargetHandle: normalizeHandle(splitHandles.target), - positionX: node.position.x, - positionY: node.position.y, + positionX: primaryNode.position.x, + positionY: primaryNode.position.y, }); } catch (error) { console.error("[Canvas edge intersection split failed]", { canvasId, - nodeId: node.id, - nodeType: node.type, + nodeId: primaryNode?.id ?? null, + nodeType: primaryNode?.type ?? null, intersectedEdgeId, error: String(error), }); @@ -1402,7 +1669,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { runBatchMoveNodesMutation, runMoveNodeMutation, setHighlightedIntersectionEdge, - splitEdgeAtExistingNodeMut, + runSplitEdgeAtExistingNodeMutation, syncPendingMoveForClientRequest, ], ); @@ -1450,6 +1717,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const handleConnectionDropPick = useCallback( (template: CanvasNodeTemplate) => { + if (!isSyncOnline) { + notifyOfflineUnsupported("Node mit Verbindung erstellen"); + return; + } const ctx = connectionDropMenuRef.current; if (!ctx) return; @@ -1489,7 +1760,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }; if (ctx.fromHandleType === "source") { - void createNodeWithEdgeFromSource({ + void runCreateNodeWithEdgeFromSourceOnlineOnly({ ...base, sourceNodeId: ctx.fromNodeId, sourceHandle: ctx.fromHandleId, @@ -1508,7 +1779,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { console.error("[Canvas] createNodeWithEdgeFromSource failed", error); }); } else { - void createNodeWithEdgeToTarget({ + void runCreateNodeWithEdgeToTargetOnlineOnly({ ...base, targetNodeId: ctx.fromNodeId, sourceHandle: handles?.source ?? undefined, @@ -1530,8 +1801,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }, [ canvasId, - createNodeWithEdgeFromSource, - createNodeWithEdgeToTarget, + isSyncOnline, + notifyOfflineUnsupported, + runCreateNodeWithEdgeFromSourceOnlineOnly, + runCreateNodeWithEdgeToTargetOnlineOnly, syncPendingMoveForClientRequest, ], ); @@ -1545,6 +1818,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const onDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); + if (!isSyncOnline) { + notifyOfflineUnsupported("Node erstellen"); + return; + } const rawData = event.dataTransfer.getData( "application/lemonspace-node-type", @@ -1578,7 +1855,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); const clientRequestId = crypto.randomUUID(); - void createNode({ + void runCreateNodeOnlineOnly({ canvasId, type: "image", positionX: position.x, @@ -1642,7 +1919,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { }; const clientRequestId = crypto.randomUUID(); - void createNode({ + void runCreateNodeOnlineOnly({ canvasId, type: nodeType, positionX: position.x, @@ -1662,7 +1939,28 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { ); }); }, - [screenToFlowPosition, createNode, canvasId, syncPendingMoveForClientRequest, generateUploadUrl], + [ + screenToFlowPosition, + canvasId, + generateUploadUrl, + isSyncOnline, + notifyOfflineUnsupported, + runCreateNodeOnlineOnly, + syncPendingMoveForClientRequest, + ], + ); + + const canvasSyncContextValue = useMemo( + () => ({ + queueNodeDataUpdate: runUpdateNodeDataMutation, + queueNodeResize: runResizeNodeMutation, + status: { + pendingCount: pendingSyncCount, + isSyncing, + isOffline: !isSyncOnline, + }, + }), + [isSyncOnline, isSyncing, pendingSyncCount, runResizeNodeMutation, runUpdateNodeDataMutation], ); // ─── Loading State ──────────────────────────────────────────── @@ -1678,24 +1976,25 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { } return ( - { - void syncPendingMoveForClientRequest(clientRequestId, realId).catch( - (error: unknown) => { - console.error( - "[Canvas] onCreateNodeSettled syncPendingMove failed", - error, - ); - }, - ); - }} - > - + + { + void syncPendingMoveForClientRequest(clientRequestId, realId).catch( + (error: unknown) => { + console.error( + "[Canvas] onCreateNodeSettled syncPendingMove failed", + error, + ); + }, + ); + }} + > +
-
-
+
+
+ ); } diff --git a/components/canvas/nodes/ai-image-node.tsx b/components/canvas/nodes/ai-image-node.tsx index 0bc9978..c139bf4 100644 --- a/components/canvas/nodes/ai-image-node.tsx +++ b/components/canvas/nodes/ai-image-node.tsx @@ -12,6 +12,7 @@ import { classifyError, type AiErrorCategory } from "@/lib/ai-errors"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; import { toast } from "@/lib/toast"; import { msg } from "@/lib/toast-messages"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { Loader2, AlertCircle, @@ -60,6 +61,7 @@ export default function AiImageNode({ }: NodeProps) { const nodeData = data as AiImageNodeData; const { getEdges, getNode } = useReactFlow(); + const { status: syncStatus } = useCanvasSync(); const router = useRouter(); const [isGenerating, setIsGenerating] = useState(false); @@ -84,6 +86,13 @@ export default function AiImageNode({ const handleRegenerate = useCallback(async () => { if (isLoading) return; + if (syncStatus.isOffline) { + toast.warning( + "Offline aktuell nicht unterstützt", + "KI-Generierung benötigt eine aktive Verbindung.", + ); + return; + } setLocalError(null); setIsGenerating(true); @@ -140,7 +149,7 @@ export default function AiImageNode({ } finally { setIsGenerating(false); } - }, [isLoading, nodeData, id, getEdges, getNode, generateImage]); + }, [isLoading, syncStatus.isOffline, nodeData, id, getEdges, getNode, generateImage]); const modelName = getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI"; diff --git a/components/canvas/nodes/asset-node.tsx b/components/canvas/nodes/asset-node.tsx index c2fbc57..949a927 100644 --- a/components/canvas/nodes/asset-node.tsx +++ b/components/canvas/nodes/asset-node.tsx @@ -9,7 +9,6 @@ import { type MouseEvent, } from "react"; import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react"; -import { useMutation } from "convex/react"; import { ExternalLink, ImageIcon } from "lucide-react"; import BaseNodeWrapper from "./base-node-wrapper"; import { @@ -17,11 +16,11 @@ import { useAssetBrowserTarget, type AssetBrowserSessionState, } from "@/components/canvas/asset-browser-panel"; -import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type AssetNodeData = { assetId?: number; @@ -55,7 +54,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro page: 1, totalPages: 1, }); - const resizeNode = useMutation(api.nodes.resize); + const { queueNodeResize } = useCanvasSync(); const edges = useStore((s) => s.edges); const nodes = useStore((s) => s.nodes); @@ -124,7 +123,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro } hasAutoSizedRef.current = true; - void resizeNode({ + void queueNodeResize({ nodeId: id as Id<"nodes">, width: targetSize.width, height: targetSize.height, @@ -136,7 +135,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro hasAsset, height, id, - resizeNode, + queueNodeResize, width, ]); diff --git a/components/canvas/nodes/frame-node.tsx b/components/canvas/nodes/frame-node.tsx index c8a8151..cd4b67a 100644 --- a/components/canvas/nodes/frame-node.tsx +++ b/components/canvas/nodes/frame-node.tsx @@ -2,7 +2,7 @@ import { useCallback, useState } from "react"; import { Handle, Position, type NodeProps } from "@xyflow/react"; -import { useAction, useMutation } from "convex/react"; +import { useAction } from "convex/react"; import { Download, Loader2 } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -10,6 +10,7 @@ import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import BaseNodeWrapper from "./base-node-wrapper"; import { toast } from "@/lib/toast"; import { msg } from "@/lib/toast-messages"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; interface FrameNodeData { label?: string; @@ -19,7 +20,7 @@ interface FrameNodeData { export default function FrameNode({ id, data, selected, width, height }: NodeProps) { const nodeData = data as FrameNodeData; - const updateData = useMutation(api.nodes.updateData); + const { queueNodeDataUpdate, status } = useCanvasSync(); const exportFrame = useAction(api.export.exportFrame); const [label, setLabel] = useState(nodeData.label ?? "Frame"); @@ -27,7 +28,10 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro const [exportError, setExportError] = useState(null); const debouncedSave = useDebouncedCallback((value: string) => { - void updateData({ nodeId: id as Id<"nodes">, data: { ...nodeData, label: value } }); + void queueNodeDataUpdate({ + nodeId: id as Id<"nodes">, + data: { ...nodeData, label: value }, + }); }, 500); const handleLabelChange = useCallback( @@ -40,6 +44,10 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro const handleExport = useCallback(async () => { if (isExporting) return; + if (status.isOffline) { + toast.warning("Offline aktuell nicht unterstützt", "Export benötigt eine aktive Verbindung."); + return; + } setIsExporting(true); setExportError(null); @@ -67,7 +75,7 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro } finally { setIsExporting(false); } - }, [exportFrame, id, isExporting, label]); + }, [exportFrame, id, isExporting, label, status.isOffline]); const frameW = Math.round(width ?? 400); const frameH = Math.round(height ?? 300); diff --git a/components/canvas/nodes/group-node.tsx b/components/canvas/nodes/group-node.tsx index 0eb66c1..1718a72 100644 --- a/components/canvas/nodes/group-node.tsx +++ b/components/canvas/nodes/group-node.tsx @@ -2,10 +2,9 @@ import { useState, useCallback, useEffect } from "react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; -import { useMutation } from "convex/react"; -import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type GroupNodeData = { label?: string; @@ -16,7 +15,7 @@ type GroupNodeData = { export type GroupNode = Node; export default function GroupNode({ id, data, selected }: NodeProps) { - const updateData = useMutation(api.nodes.updateData); + const { queueNodeDataUpdate } = useCanvasSync(); const [label, setLabel] = useState(data.label ?? "Gruppe"); const [isEditing, setIsEditing] = useState(false); @@ -30,7 +29,7 @@ export default function GroupNode({ id, data, selected }: NodeProps) const handleBlur = useCallback(() => { setIsEditing(false); if (label !== data.label) { - updateData({ + void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...data, @@ -40,7 +39,7 @@ export default function GroupNode({ id, data, selected }: NodeProps) }, }); } - }, [label, data, id, updateData]); + }, [label, data, id, queueNodeDataUpdate]); return ( ) { const generateUploadUrl = useMutation(api.storage.generateUploadUrl); - const updateData = useMutation(api.nodes.updateData); - const resizeNode = useMutation(api.nodes.resize); + const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [isDragOver, setIsDragOver] = useState(false); @@ -111,12 +111,12 @@ export default function ImageNode({ } hasAutoSizedRef.current = true; - void resizeNode({ + void queueNodeResize({ nodeId: id as Id<"nodes">, width: targetSize.width, height: targetSize.height, }); - }, [data.height, data.width, height, id, resizeNode, width]); + }, [data.height, data.width, height, id, queueNodeResize, width]); const uploadFile = useCallback( async (file: File) => { @@ -134,6 +134,13 @@ export default function ImageNode({ toast.error(title, desc); return; } + if (status.isOffline) { + toast.warning( + "Offline aktuell nicht unterstützt", + "Bild-Uploads benötigen eine aktive Verbindung.", + ); + return; + } setIsUploading(true); @@ -158,7 +165,7 @@ export default function ImageNode({ const { storageId } = (await result.json()) as { storageId: string }; - await updateData({ + await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { storageId, @@ -174,7 +181,7 @@ export default function ImageNode({ intrinsicHeight: dimensions.height, }); - await resizeNode({ + await queueNodeResize({ nodeId: id as Id<"nodes">, width: targetSize.width, height: targetSize.height, @@ -192,7 +199,7 @@ export default function ImageNode({ setIsUploading(false); } }, - [id, generateUploadUrl, resizeNode, updateData] + [generateUploadUrl, id, queueNodeDataUpdate, queueNodeResize, status.isOffline] ); const handleClick = useCallback(() => { diff --git a/components/canvas/nodes/note-node.tsx b/components/canvas/nodes/note-node.tsx index 0acef2e..13fdff1 100644 --- a/components/canvas/nodes/note-node.tsx +++ b/components/canvas/nodes/note-node.tsx @@ -2,11 +2,10 @@ import { useState, useCallback, useEffect } from "react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; -import { useMutation } from "convex/react"; -import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import BaseNodeWrapper from "./base-node-wrapper"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type NoteNodeData = { content?: string; @@ -17,7 +16,7 @@ type NoteNodeData = { export type NoteNode = Node; export default function NoteNode({ id, data, selected }: NodeProps) { - const updateData = useMutation(api.nodes.updateData); + const { queueNodeDataUpdate } = useCanvasSync(); const [content, setContent] = useState(data.content ?? ""); const [isEditing, setIsEditing] = useState(false); @@ -30,7 +29,7 @@ export default function NoteNode({ id, data, selected }: NodeProps) { const saveContent = useDebouncedCallback( (newContent: string) => { - updateData({ + void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...data, diff --git a/components/canvas/nodes/prompt-node.tsx b/components/canvas/nodes/prompt-node.tsx index c59e20a..e6691b9 100644 --- a/components/canvas/nodes/prompt-node.tsx +++ b/components/canvas/nodes/prompt-node.tsx @@ -9,12 +9,13 @@ import { type NodeProps, type Node, } from "@xyflow/react"; -import { useMutation, useAction } from "convex/react"; +import { useAction } from "convex/react"; import { useAuthQuery } from "@/hooks/use-auth-query"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import BaseNodeWrapper from "./base-node-wrapper"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { @@ -118,7 +119,7 @@ export default function PromptNode({ const hasEnoughCredits = availableCredits !== null && availableCredits >= creditCost; - const updateData = useMutation(api.nodes.updateData); + const { queueNodeDataUpdate, status } = useCanvasSync(); const generateImage = useAction(api.ai.generateImage); const { createNodeConnectedFromSource } = useCanvasPlacement(); @@ -127,7 +128,7 @@ export default function PromptNode({ const { _status, _statusMessage, ...rest } = raw; void _status; void _statusMessage; - updateData({ + void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...rest, @@ -156,6 +157,13 @@ export default function PromptNode({ const handleGenerate = useCallback(async () => { if (!effectivePrompt.trim() || isGenerating) return; + if (status.isOffline) { + toast.warning( + "Offline aktuell nicht unterstützt", + "KI-Generierung benötigt eine aktive Verbindung.", + ); + return; + } if (availableCredits !== null && !hasEnoughCredits) { const { title, desc } = msg.ai.insufficientCredits( @@ -291,6 +299,7 @@ export default function PromptNode({ availableCredits, hasEnoughCredits, router, + status.isOffline, ]); return ( diff --git a/components/canvas/nodes/text-node.tsx b/components/canvas/nodes/text-node.tsx index f8ad917..b7114f0 100644 --- a/components/canvas/nodes/text-node.tsx +++ b/components/canvas/nodes/text-node.tsx @@ -8,11 +8,10 @@ import { type NodeProps, type Node, } from "@xyflow/react"; -import { useMutation } from "convex/react"; -import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import BaseNodeWrapper from "./base-node-wrapper"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type TextNodeData = { content?: string; @@ -24,7 +23,7 @@ export type TextNode = Node; export default function TextNode({ id, data, selected }: NodeProps) { const { setNodes } = useReactFlow(); - const updateData = useMutation(api.nodes.updateData); + const { queueNodeDataUpdate } = useCanvasSync(); const [content, setContent] = useState(data.content ?? ""); const [isEditing, setIsEditing] = useState(false); @@ -39,7 +38,7 @@ export default function TextNode({ id, data, selected }: NodeProps) { // Debounced Save — 500ms nach letztem Tastendruck const saveContent = useDebouncedCallback( (newContent: string) => { - updateData({ + void queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...data, diff --git a/components/canvas/nodes/video-node.tsx b/components/canvas/nodes/video-node.tsx index 78f9dc9..96e2192 100644 --- a/components/canvas/nodes/video-node.tsx +++ b/components/canvas/nodes/video-node.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Handle, Position, useStore, type NodeProps } from "@xyflow/react"; -import { useAction, useMutation } from "convex/react"; +import { useAction } from "convex/react"; import { Play } from "lucide-react"; import BaseNodeWrapper from "./base-node-wrapper"; import { @@ -11,6 +11,7 @@ import { } from "@/components/canvas/video-browser-panel"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type VideoNodeData = { canvasId?: string; @@ -50,8 +51,7 @@ export default function VideoNode({ page: 1, totalPages: 1, }); - const resizeNode = useMutation(api.nodes.resize); - const updateData = useMutation(api.nodes.updateData); + const { queueNodeDataUpdate, queueNodeResize } = useCanvasSync(); const refreshPexelsPlayback = useAction(api.pexels.getVideoByPexelsId); const edges = useStore((s) => s.edges); @@ -95,7 +95,7 @@ export default function VideoNode({ void (async () => { try { const fresh = await refreshPexelsPlayback({ pexelsId }); - await updateData({ + await queueNodeDataUpdate({ nodeId: id as Id<"nodes">, data: { ...d, @@ -109,7 +109,7 @@ export default function VideoNode({ playbackRefreshAttempted.current = false; } })(); - }, [d, id, refreshPexelsPlayback, updateData]); + }, [d, id, queueNodeDataUpdate, refreshPexelsPlayback]); useEffect(() => { if (!hasVideo) return; @@ -134,12 +134,12 @@ export default function VideoNode({ const targetWidth = 320; const targetHeight = Math.round(targetWidth / aspectRatio); - void resizeNode({ + void queueNodeResize({ nodeId: id as Id<"nodes">, width: targetWidth, height: targetHeight, }); - }, [d.width, d.height, hasVideo, height, id, resizeNode, width]); + }, [d.width, d.height, hasVideo, height, id, queueNodeResize, width]); const showPreview = hasVideo && d.thumbnailUrl; diff --git a/components/canvas/video-browser-panel.tsx b/components/canvas/video-browser-panel.tsx index 1b8acf6..2dab2c3 100644 --- a/components/canvas/video-browser-panel.tsx +++ b/components/canvas/video-browser-panel.tsx @@ -10,7 +10,7 @@ import { type PointerEvent, } from "react"; import { createPortal } from "react-dom"; -import { useAction, useMutation } from "convex/react"; +import { useAction } from "convex/react"; import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; @@ -19,6 +19,7 @@ import { Button } from "@/components/ui/button"; import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types"; import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types"; import { toast } from "@/lib/toast"; +import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type Orientation = "" | "landscape" | "portrait" | "square"; type DurationFilter = "all" | "short" | "medium" | "long"; @@ -82,8 +83,7 @@ export function VideoBrowserPanel({ const searchVideos = useAction(api.pexels.searchVideos); const popularVideos = useAction(api.pexels.popularVideos); - const updateData = useMutation(api.nodes.updateData); - const resizeNode = useMutation(api.nodes.resize); + const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef( Boolean(initialState?.results?.length), ); @@ -197,6 +197,13 @@ export function VideoBrowserPanel({ const handleSelect = useCallback( async (video: PexelsVideo) => { if (isSelecting) return; + if (status.isOffline) { + toast.warning( + "Offline aktuell nicht unterstützt", + "Video-Auswahl benötigt eine aktive Verbindung.", + ); + return; + } setSelectingVideoId(video.id); let file: PexelsVideoFile; try { @@ -209,7 +216,7 @@ export function VideoBrowserPanel({ return; } try { - await updateData({ + await queueNodeDataUpdate({ nodeId: nodeId as Id<"nodes">, data: { pexelsId: video.id, @@ -234,7 +241,7 @@ export function VideoBrowserPanel({ : 16 / 9; const targetWidth = 320; const targetHeight = Math.round(targetWidth / aspectRatio); - await resizeNode({ + await queueNodeResize({ nodeId: nodeId as Id<"nodes">, width: targetWidth, height: targetHeight, @@ -246,7 +253,7 @@ export function VideoBrowserPanel({ setSelectingVideoId(null); } }, - [canvasId, isSelecting, nodeId, onClose, resizeNode, updateData], + [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], ); const handlePreviousPage = useCallback(() => { diff --git a/lib/canvas-local-persistence.ts b/lib/canvas-local-persistence.ts index 3a04f3c..489f4ba 100644 --- a/lib/canvas-local-persistence.ts +++ b/lib/canvas-local-persistence.ts @@ -151,6 +151,7 @@ export function enqueueCanvasOp( enqueuedAt: op.enqueuedAt ?? Date.now(), }; const payload = readOpsPayload(canvasId); + payload.ops = payload.ops.filter((candidate) => candidate.id !== entry.id); payload.ops.push(entry); payload.updatedAt = Date.now(); writePayload(opsKey(canvasId), payload); @@ -166,6 +167,17 @@ export function resolveCanvasOp(canvasId: string, opId: string): void { writePayload(opsKey(canvasId), payload); } +export function resolveCanvasOps(canvasId: string, opIds: string[]): void { + if (opIds.length === 0) return; + const idSet = new Set(opIds); + const payload = readOpsPayload(canvasId); + const nextOps = payload.ops.filter((op) => !idSet.has(op.id)); + if (nextOps.length === payload.ops.length) return; + payload.ops = nextOps; + payload.updatedAt = Date.now(); + writePayload(opsKey(canvasId), payload); +} + export function readCanvasOps(canvasId: string): CanvasPendingOp[] { return readOpsPayload(canvasId).ops; } diff --git a/lib/canvas-op-queue.ts b/lib/canvas-op-queue.ts new file mode 100644 index 0000000..18d06ff --- /dev/null +++ b/lib/canvas-op-queue.ts @@ -0,0 +1,420 @@ +import type { Id } from "@/convex/_generated/dataModel"; + +const DB_NAME = "lemonspace.canvas.sync"; +const DB_VERSION = 1; +const STORE_NAME = "ops"; +const FALLBACK_STORAGE_KEY = "lemonspace.canvas:sync-fallback:v1"; +export const CANVAS_SYNC_RETENTION_MS = 24 * 60 * 60 * 1000; + +export type CanvasSyncOpPayloadByType = { + moveNode: { nodeId: Id<"nodes">; positionX: number; positionY: number }; + resizeNode: { nodeId: Id<"nodes">; width: number; height: number }; + updateData: { nodeId: Id<"nodes">; data: unknown }; +}; + +export type CanvasSyncOpType = keyof CanvasSyncOpPayloadByType; + +type CanvasSyncOpBase = { + id: string; + canvasId: string; + enqueuedAt: number; + attemptCount: number; + nextRetryAt: number; + expiresAt: number; + lastError?: string; +}; + +export type CanvasSyncOp = { + [TType in CanvasSyncOpType]: CanvasSyncOpBase & { + type: TType; + payload: CanvasSyncOpPayloadByType[TType]; + }; +}[CanvasSyncOpType]; + +type CanvasSyncOpFor = Extract< + CanvasSyncOp, + { type: TType } +>; + +type JsonRecord = Record; + +type EnqueueInput = { + id: string; + canvasId: string; + type: TType; + payload: CanvasSyncOpPayloadByType[TType]; + now?: number; +}; + +let dbPromise: Promise | null = null; + +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 readFallbackOps(): CanvasSyncOp[] { + const storage = getLocalStorage(); + if (!storage) return []; + const parsed = safeParse(storage.getItem(FALLBACK_STORAGE_KEY)); + if (!Array.isArray(parsed)) return []; + return parsed + .filter((entry): entry is JsonRecord => isRecord(entry)) + .map(normalizeOp) + .filter((entry): entry is CanvasSyncOp => entry !== null); +} + +function writeFallbackOps(ops: CanvasSyncOp[]): void { + const storage = getLocalStorage(); + if (!storage) return; + try { + storage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(ops)); + } catch { + // Ignore storage quota failures in fallback layer. + } +} + +function openDb(): Promise { + if (typeof window === "undefined" || typeof indexedDB === "undefined") { + return Promise.resolve(null); + } + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (db.objectStoreNames.contains(STORE_NAME)) return; + const store = db.createObjectStore(STORE_NAME, { keyPath: "id" }); + store.createIndex("by_canvas", "canvasId", { unique: false }); + store.createIndex("by_nextRetryAt", "nextRetryAt", { unique: false }); + }; + + request.onsuccess = () => { + const db = request.result; + db.onversionchange = () => { + db.close(); + dbPromise = null; + }; + resolve(db); + }; + + request.onerror = () => { + resolve(null); + }; + }); + + return dbPromise; +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }); +} + +function reqToPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed")); + }); +} + +function getNodeIdFromOp(op: CanvasSyncOp): string { + const payload = op.payload as { nodeId?: string }; + return typeof payload.nodeId === "string" ? payload.nodeId : ""; +} + +function normalizeOp(raw: unknown): CanvasSyncOp | null { + if (!isRecord(raw)) return null; + const id = raw.id; + const canvasId = raw.canvasId; + const type = raw.type; + const payload = raw.payload; + if ( + typeof id !== "string" || + !id || + typeof canvasId !== "string" || + !canvasId || + (type !== "moveNode" && type !== "resizeNode" && type !== "updateData") + ) { + return null; + } + + const enqueuedAt = typeof raw.enqueuedAt === "number" ? raw.enqueuedAt : Date.now(); + const attemptCount = typeof raw.attemptCount === "number" ? raw.attemptCount : 0; + const nextRetryAt = + typeof raw.nextRetryAt === "number" ? raw.nextRetryAt : enqueuedAt; + const expiresAt = + typeof raw.expiresAt === "number" + ? raw.expiresAt + : enqueuedAt + CANVAS_SYNC_RETENTION_MS; + const lastError = typeof raw.lastError === "string" ? raw.lastError : undefined; + + if (!isRecord(payload)) return null; + + if ( + type === "moveNode" && + typeof payload.nodeId === "string" && + typeof payload.positionX === "number" && + typeof payload.positionY === "number" + ) { + return { + id, + canvasId, + type, + payload: { + nodeId: payload.nodeId as Id<"nodes">, + positionX: payload.positionX, + positionY: payload.positionY, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + if ( + type === "resizeNode" && + typeof payload.nodeId === "string" && + typeof payload.width === "number" && + typeof payload.height === "number" + ) { + return { + id, + canvasId, + type, + payload: { + nodeId: payload.nodeId as Id<"nodes">, + width: payload.width, + height: payload.height, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + if (type === "updateData" && typeof payload.nodeId === "string") { + return { + id, + canvasId, + type, + payload: { + nodeId: payload.nodeId as Id<"nodes">, + data: payload.data, + }, + enqueuedAt, + attemptCount, + nextRetryAt, + expiresAt, + lastError, + }; + } + + return null; +} + +function sortByEnqueued(a: CanvasSyncOp, b: CanvasSyncOp): number { + if (a.enqueuedAt === b.enqueuedAt) return a.id.localeCompare(b.id); + return a.enqueuedAt - b.enqueuedAt; +} + +function toStoredOp( + input: EnqueueInput, +): CanvasSyncOpFor { + const now = input.now ?? Date.now(); + return { + id: input.id, + canvasId: input.canvasId, + type: input.type, + payload: input.payload, + enqueuedAt: now, + attemptCount: 0, + nextRetryAt: now, + expiresAt: now + CANVAS_SYNC_RETENTION_MS, + } as CanvasSyncOpFor; +} + +function coalescingNodeId( + op: Pick, +): string | null { + if (op.type !== "moveNode" && op.type !== "resizeNode" && op.type !== "updateData") { + return null; + } + const payload = op.payload as { nodeId?: string }; + return typeof payload.nodeId === "string" && payload.nodeId.length > 0 + ? payload.nodeId + : null; +} + +export async function listCanvasSyncOps(canvasId: string): Promise { + const db = await openDb(); + if (!db) { + return readFallbackOps() + .filter((entry) => entry.canvasId === canvasId) + .sort(sortByEnqueued); + } + + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const byCanvas = store.index("by_canvas"); + const records = await reqToPromise(byCanvas.getAll(canvasId)); + return (records as unknown[]) + .map(normalizeOp) + .filter((entry): entry is CanvasSyncOp => entry !== null) + .sort(sortByEnqueued); +} + +export async function countCanvasSyncOps(canvasId: string): Promise { + const db = await openDb(); + if (!db) { + return readFallbackOps().filter((entry) => entry.canvasId === canvasId).length; + } + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const byCanvas = store.index("by_canvas"); + const count = await reqToPromise(byCanvas.count(canvasId)); + return count; +} + +export async function enqueueCanvasSyncOp( + input: EnqueueInput, +): Promise<{ replacedIds: string[] }> { + const op = toStoredOp(input); + const existing = await listCanvasSyncOps(input.canvasId); + const nodeId = coalescingNodeId(op); + const replacedIds: string[] = []; + + for (const candidate of existing) { + if (candidate.type !== op.type) continue; + if (nodeId === null) continue; + if (getNodeIdFromOp(candidate) !== nodeId) continue; + replacedIds.push(candidate.id); + } + + const db = await openDb(); + if (!db) { + const fallback = readFallbackOps().filter( + (entry) => !replacedIds.includes(entry.id), + ); + fallback.push(op); + writeFallbackOps(fallback); + return { replacedIds }; + } + + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + for (const id of replacedIds) { + store.delete(id); + } + store.put(op); + await txDone(tx); + return { replacedIds }; +} + +export async function ackCanvasSyncOp(opId: string): Promise { + const db = await openDb(); + if (!db) { + const fallback = readFallbackOps().filter((entry) => entry.id !== opId); + writeFallbackOps(fallback); + return; + } + + const tx = db.transaction(STORE_NAME, "readwrite"); + tx.objectStore(STORE_NAME).delete(opId); + await txDone(tx); +} + +export async function markCanvasSyncOpFailed( + opId: string, + opts: { nextRetryAt: number; lastError?: string }, +): Promise { + const db = await openDb(); + if (!db) { + const fallback = readFallbackOps().map((entry) => { + if (entry.id !== opId) return entry; + return { + ...entry, + attemptCount: entry.attemptCount + 1, + nextRetryAt: opts.nextRetryAt, + lastError: opts.lastError, + }; + }); + writeFallbackOps(fallback); + return; + } + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const getReq = store.get(opId); + + getReq.onerror = () => reject(getReq.error ?? new Error("IndexedDB get failed")); + getReq.onsuccess = () => { + const current = normalizeOp(getReq.result); + if (!current) return; + const next: CanvasSyncOp = { + ...current, + attemptCount: current.attemptCount + 1, + nextRetryAt: opts.nextRetryAt, + lastError: opts.lastError, + }; + store.put(next); + }; + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }); +} + +export async function dropExpiredCanvasSyncOps( + canvasId: string, + now: number, +): Promise { + const all = await listCanvasSyncOps(canvasId); + const expiredIds = all + .filter((entry) => entry.expiresAt <= now) + .map((entry) => entry.id); + if (expiredIds.length === 0) return []; + + const db = await openDb(); + if (!db) { + const fallback = readFallbackOps().filter((entry) => !expiredIds.includes(entry.id)); + writeFallbackOps(fallback); + return expiredIds; + } + + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + for (const id of expiredIds) { + store.delete(id); + } + await txDone(tx); + return expiredIds; +}