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;
+}