Implement local-first canvas sync and fix drag edge stability

This commit is contained in:
Matthias
2026-04-01 09:40:31 +02:00
parent c1d7a49bc3
commit 32bd188d89
19 changed files with 1095 additions and 283 deletions

View File

@@ -13,7 +13,7 @@ app/
├── globals.css ← Tailwind v4 + Design-Tokens ├── globals.css ← Tailwind v4 + Design-Tokens
├── (app)/ ← Authentifizierte App-Routen ├── (app)/ ← Authentifizierte App-Routen
│ ├── canvas/[canvasId]/ ← Canvas-Editor │ ├── canvas/[canvasId]/ ← Canvas-Editor
│ │ └── page.tsx │ │ └── page.tsx ← SSR-Auth/ID-Validation, rendert dann `CanvasShell`
│ └── settings/ │ └── settings/
│ └── billing/ ← Billing-Einstellungen │ └── billing/ ← Billing-Einstellungen
├── auth/ ← Auth-Routen (Better Auth) ├── auth/ ← Auth-Routen (Better Auth)

View File

@@ -8,6 +8,9 @@ Der Canvas ist das Herzstück von LemonSpace. Er basiert auf `@xyflow/react` (Re
``` ```
app/(app)/canvas/[canvasId]/page.tsx app/(app)/canvas/[canvasId]/page.tsx
└── <CanvasShell canvasId={...} /> ← components/canvas/canvas-shell.tsx
├── Resizable Sidebar/Main Layout ← shadcn `resizable`
├── <CanvasSidebar railMode={...}> ← collapsible Rail/Fulllayout
└── <Canvas canvasId={...} /> ← components/canvas/canvas.tsx └── <Canvas canvasId={...} /> ← components/canvas/canvas.tsx
├── <ReactFlowProvider> ├── <ReactFlowProvider>
│ └── <CanvasInner> ← Haupt-Komponente (~1800 Zeilen) │ └── <CanvasInner> ← Haupt-Komponente (~1800 Zeilen)
@@ -124,9 +127,10 @@ Compare-Node hat zusätzlich Handle-spezifische Farben (`left` → Blau, `right`
| Datei | Zweck | | 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-toolbar.tsx` | Werkzeug-Leiste (Select, Pan, Zoom-Controls) |
| `canvas-app-menu.tsx` | App-Menü (Einstellungen, Logout, Canvas-Name) | | `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-command-palette.tsx` | Cmd+K Command Palette |
| `canvas-connection-drop-menu.tsx` | Kontext-Menü beim Loslassen einer Verbindung | | `canvas-connection-drop-menu.tsx` | Kontext-Menü beim Loslassen einer Verbindung |
| `canvas-node-template-picker.tsx` | Node aus Template einfügen | | `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 ## 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. - **`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.

View File

@@ -10,7 +10,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { createPortal } from "react-dom"; 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 { X, Search, Loader2, AlertCircle } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { toast } from "@/lib/toast";
type AssetType = "photo" | "vector" | "icon"; type AssetType = "photo" | "vector" | "icon";
@@ -88,8 +90,7 @@ export function AssetBrowserPanel({
const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null); const [selectingAssetKey, setSelectingAssetKey] = useState<string | null>(null);
const searchFreepik = useAction(api.freepik.search); const searchFreepik = useAction(api.freepik.search);
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const resizeNode = useMutation(api.nodes.resize);
const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length)); const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length));
const requestSequenceRef = useRef(0); const requestSequenceRef = useRef(0);
const scrollAreaRef = useRef<HTMLDivElement | null>(null); const scrollAreaRef = useRef<HTMLDivElement | null>(null);
@@ -187,10 +188,17 @@ export function AssetBrowserPanel({
const handleSelect = useCallback( const handleSelect = useCallback(
async (asset: FreepikResult) => { async (asset: FreepikResult) => {
if (isSelecting) return; 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}`; const assetKey = `${asset.assetType}-${asset.id}`;
setSelectingAssetKey(assetKey); setSelectingAssetKey(assetKey);
try { try {
await updateData({ await queueNodeDataUpdate({
nodeId: nodeId as Id<"nodes">, nodeId: nodeId as Id<"nodes">,
data: { data: {
assetId: asset.id, assetId: asset.id,
@@ -214,7 +222,7 @@ export function AssetBrowserPanel({
orientation: asset.orientation, orientation: asset.orientation,
}); });
await resizeNode({ await queueNodeResize({
nodeId: nodeId as Id<"nodes">, nodeId: nodeId as Id<"nodes">,
width: targetSize.width, width: targetSize.width,
height: targetSize.height, height: targetSize.height,
@@ -226,7 +234,7 @@ export function AssetBrowserPanel({
setSelectingAssetKey(null); setSelectingAssetKey(null);
} }
}, },
[canvasId, isSelecting, nodeId, onClose, resizeNode, updateData], [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline],
); );
const handlePreviousPage = useCallback(() => { const handlePreviousPage = useCallback(() => {

View File

@@ -16,6 +16,7 @@ import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers";
type UseCanvasDeleteHandlersParams = { type UseCanvasDeleteHandlersParams = {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
isOffline: boolean;
nodes: RFNode[]; nodes: RFNode[];
edges: RFEdge[]; edges: RFEdge[];
deletingNodeIds: MutableRefObject<Set<string>>; deletingNodeIds: MutableRefObject<Set<string>>;
@@ -33,6 +34,7 @@ type UseCanvasDeleteHandlersParams = {
export function useCanvasDeleteHandlers({ export function useCanvasDeleteHandlers({
canvasId, canvasId,
isOffline,
nodes, nodes,
edges, edges,
deletingNodeIds, deletingNodeIds,
@@ -53,6 +55,14 @@ export function useCanvasDeleteHandlers({
nodes: RFNode[]; nodes: RFNode[];
edges: RFEdge[]; 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) { if (matchingNodes.length === 0) {
return true; return true;
} }
@@ -90,7 +100,7 @@ export function useCanvasDeleteHandlers({
return true; return true;
}, },
[], [isOffline],
); );
const onNodesDelete = useCallback( const onNodesDelete = useCallback(

View File

@@ -7,18 +7,12 @@ import {
useMemo, useMemo,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import type { ReactMutation } from "convex/react";
import type { FunctionReference } from "convex/server";
import { useStore, type Edge as RFEdge } from "@xyflow/react"; import { useStore, type Edge as RFEdge } from "@xyflow/react";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
type CreateNodeMutation = ReactMutation< type CreateNodeArgs = {
FunctionReference<
"mutation",
"public",
{
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
type: string; type: string;
positionX: number; positionX: number;
@@ -29,16 +23,9 @@ type CreateNodeMutation = ReactMutation<
parentId?: Id<"nodes">; parentId?: Id<"nodes">;
zIndex?: number; zIndex?: number;
clientRequestId?: string; clientRequestId?: string;
}, };
Id<"nodes">
>
>;
type CreateNodeWithEdgeSplitMutation = ReactMutation< type CreateNodeWithEdgeSplitArgs = {
FunctionReference<
"mutation",
"public",
{
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
type: string; type: string;
positionX: number; positionX: number;
@@ -53,56 +40,30 @@ type CreateNodeWithEdgeSplitMutation = ReactMutation<
newNodeSourceHandle?: string; newNodeSourceHandle?: string;
splitSourceHandle?: string; splitSourceHandle?: string;
splitTargetHandle?: string; splitTargetHandle?: string;
}, };
Id<"nodes">
>
>;
type CreateNodeWithEdgeFromSourceMutation = ReactMutation< type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
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">; sourceNodeId: Id<"nodes">;
sourceHandle?: string; sourceHandle?: string;
targetHandle?: string; targetHandle?: string;
}, };
Id<"nodes">
>
>;
type CreateNodeWithEdgeToTargetMutation = ReactMutation< type CreateNodeWithEdgeToTargetArgs = CreateNodeArgs & {
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">; targetNodeId: Id<"nodes">;
sourceHandle?: string; sourceHandle?: string;
targetHandle?: string; targetHandle?: string;
}, };
Id<"nodes">
> type CreateNodeMutation = (args: CreateNodeArgs) => Promise<Id<"nodes">>;
>; type CreateNodeWithEdgeSplitMutation = (
args: CreateNodeWithEdgeSplitArgs,
) => Promise<Id<"nodes">>;
type CreateNodeWithEdgeFromSourceMutation = (
args: CreateNodeWithEdgeFromSourceArgs,
) => Promise<Id<"nodes">>;
type CreateNodeWithEdgeToTargetMutation = (
args: CreateNodeWithEdgeToTargetArgs,
) => Promise<Id<"nodes">>;
type FlowPoint = { x: number; y: number }; type FlowPoint = { x: number; y: number };
@@ -296,6 +257,12 @@ export function CanvasPlacementProvider({
notifySettled(realId); notifySettled(realId);
return realId; return realId;
} catch (error) { } catch (error) {
if (
error instanceof Error &&
error.message === "offline-unsupported"
) {
throw error;
}
console.error("[Canvas placement] edge split failed", { console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id, edgeId: hitEdge.id,
type, type,

View File

@@ -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<void>;
queueNodeResize: (args: {
nodeId: Id<"nodes">;
width: number;
height: number;
}) => Promise<void>;
status: CanvasSyncStatus;
};
const CanvasSyncContext = createContext<CanvasSyncContextValue | null>(null);
export function CanvasSyncProvider({
value,
children,
}: {
value: CanvasSyncContextValue;
children: ReactNode;
}) {
return (
<CanvasSyncContext.Provider value={value}>{children}</CanvasSyncContext.Provider>
);
}
export function useCanvasSync(): CanvasSyncContextValue {
const context = useContext(CanvasSyncContext);
if (!context) {
throw new Error("useCanvasSync must be used within CanvasSyncProvider");
}
return context;
}

View File

@@ -35,10 +35,25 @@ import {
enqueueCanvasOp, enqueueCanvasOp,
readCanvasSnapshot, readCanvasSnapshot,
resolveCanvasOp, resolveCanvasOp,
resolveCanvasOps,
writeCanvasSnapshot, writeCanvasSnapshot,
} from "@/lib/canvas-local-persistence"; } 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 { api } from "@/convex/_generated/api";
import type { Doc, Id } from "@/convex/_generated/dataModel"; import type { Doc, Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
@@ -103,11 +118,31 @@ import { useCanvasDeleteHandlers } from "./canvas-delete-handlers";
import { getImageDimensions } from "./canvas-media-utils"; import { getImageDimensions } from "./canvas-media-utils";
import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import { useCanvasScissors } from "./canvas-scissors"; import { useCanvasScissors } from "./canvas-scissors";
import { CanvasSyncProvider } from "./canvas-sync-context";
interface CanvasInnerProps { interface CanvasInnerProps {
canvasId: Id<"canvases">; 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) { function CanvasInner({ canvasId }: CanvasInnerProps) {
const { screenToFlowPosition } = useReactFlow(); const { screenToFlowPosition } = useReactFlow();
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@@ -176,8 +211,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ── // ─── Convex Mutations (exakte Signaturen aus nodes.ts / edges.ts) ──
const moveNode = useMutation(api.nodes.move); const moveNode = useMutation(api.nodes.move);
const resizeNode = useMutation(api.nodes.resize); const resizeNode = useMutation(api.nodes.resize);
const updateNodeData = useMutation(api.nodes.updateData);
const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const batchMoveNodes = useMutation(api.nodes.batchMove); const connectionState = useConvexConnectionState();
const pendingMoveAfterCreateRef = useRef( const pendingMoveAfterCreateRef = useRef(
new Map<string, { positionX: number; positionY: number }>(), new Map<string, { positionX: number; positionY: number }>(),
); );
@@ -357,17 +393,19 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const edgeList = localStore.getQuery(api.edges.list, { canvasId }); const edgeList = localStore.getQuery(api.edges.list, { canvasId });
if (nodeList === undefined || edgeList === undefined) return; if (nodeList === undefined || edgeList === undefined) return;
const removeSet = new Set<string>(args.nodeIds.map((id) => id as string)); const removeSet = new Set<string>(
args.nodeIds.map((id: Id<"nodes">) => id as string),
);
localStore.setQuery( localStore.setQuery(
api.nodes.list, api.nodes.list,
{ canvasId }, { canvasId },
nodeList.filter((n) => !removeSet.has(n._id)), nodeList.filter((n: Doc<"nodes">) => !removeSet.has(n._id)),
); );
localStore.setQuery( localStore.setQuery(
api.edges.list, api.edges.list,
{ canvasId }, { canvasId },
edgeList.filter( edgeList.filter(
(e) => (e: Doc<"edges">) =>
!removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId), !removeSet.has(e.sourceNodeId) && !removeSet.has(e.targetNodeId),
), ),
); );
@@ -406,87 +444,279 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
localStore.setQuery( localStore.setQuery(
api.edges.list, api.edges.list,
{ canvasId }, { 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<typeof createNode>[0]) => {
if (!isSyncOnline) {
notifyOfflineUnsupported("Node erstellen");
throw new Error("offline-unsupported");
}
return await createNode(args);
},
[createNode, isSyncOnline, notifyOfflineUnsupported],
);
const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback(
async (args: Parameters<typeof createNodeWithEdgeFromSource>[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<typeof createNodeWithEdgeToTarget>[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<typeof createNodeWithEdgeSplit>[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 <TType extends keyof CanvasSyncOpPayloadByType>(
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( const runMoveNodeMutation = useCallback(
async (args: { nodeId: Id<"nodes">; positionX: number; positionY: number }) => { async (args: { nodeId: Id<"nodes">; positionX: number; positionY: number }) => {
const opId = createCanvasOpId(); await enqueueSyncMutation("moveNode", args);
enqueueCanvasOp(canvasId, { id: opId, type: "moveNode", payload: args });
try {
return await moveNode(args);
} finally {
resolveCanvasOp(canvasId, opId);
}
}, },
[canvasId, moveNode], [enqueueSyncMutation],
); );
const runBatchMoveNodesMutation = useCallback( const runBatchMoveNodesMutation = useCallback(
async (args: Parameters<typeof batchMoveNodes>[0]) => { async (args: {
const opId = createCanvasOpId(); moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[];
enqueueCanvasOp(canvasId, { id: opId, type: "batchMoveNodes", payload: args }); }) => {
try { for (const move of args.moves) {
return await batchMoveNodes(args); await enqueueSyncMutation("moveNode", move);
} finally {
resolveCanvasOp(canvasId, opId);
} }
}, },
[batchMoveNodes, canvasId], [enqueueSyncMutation],
); );
const runResizeNodeMutation = useCallback( const runResizeNodeMutation = useCallback(
async (args: { nodeId: Id<"nodes">; width: number; height: number }) => { async (args: { nodeId: Id<"nodes">; width: number; height: number }) => {
const opId = createCanvasOpId(); await enqueueSyncMutation("resizeNode", args);
enqueueCanvasOp(canvasId, { id: opId, type: "resizeNode", payload: args });
try {
return await resizeNode(args);
} finally {
resolveCanvasOp(canvasId, opId);
}
}, },
[canvasId, resizeNode], [enqueueSyncMutation],
);
const runUpdateNodeDataMutation = useCallback(
async (args: { nodeId: Id<"nodes">; data: unknown }) => {
await enqueueSyncMutation("updateData", args);
},
[enqueueSyncMutation],
); );
const runBatchRemoveNodesMutation = useCallback( const runBatchRemoveNodesMutation = useCallback(
async (args: Parameters<typeof batchRemoveNodes>[0]) => { async (args: Parameters<typeof batchRemoveNodes>[0]) => {
const opId = createCanvasOpId(); if (!isSyncOnline) {
enqueueCanvasOp(canvasId, { id: opId, type: "batchRemoveNodes", payload: args }); notifyOfflineUnsupported("Löschen");
try { return;
return await batchRemoveNodes(args);
} finally {
resolveCanvasOp(canvasId, opId);
} }
await batchRemoveNodes(args);
}, },
[batchRemoveNodes, canvasId], [batchRemoveNodes, isSyncOnline, notifyOfflineUnsupported],
); );
const runCreateEdgeMutation = useCallback( const runCreateEdgeMutation = useCallback(
async (args: Parameters<typeof createEdge>[0]) => { async (args: Parameters<typeof createEdge>[0]) => {
const opId = createCanvasOpId(); if (!isSyncOnline) {
enqueueCanvasOp(canvasId, { id: opId, type: "createEdge", payload: args }); notifyOfflineUnsupported("Kante erstellen");
try { return;
return await createEdge(args);
} finally {
resolveCanvasOp(canvasId, opId);
} }
await createEdge(args);
}, },
[canvasId, createEdge], [createEdge, isSyncOnline, notifyOfflineUnsupported],
); );
const runRemoveEdgeMutation = useCallback( const runRemoveEdgeMutation = useCallback(
async (args: Parameters<typeof removeEdge>[0]) => { async (args: Parameters<typeof removeEdge>[0]) => {
const opId = createCanvasOpId(); if (!isSyncOnline) {
enqueueCanvasOp(canvasId, { id: opId, type: "removeEdge", payload: args }); notifyOfflineUnsupported("Kante entfernen");
try { return;
return await removeEdge(args);
} finally {
resolveCanvasOp(canvasId, opId);
} }
await removeEdge(args);
}, },
[canvasId, removeEdge], [isSyncOnline, notifyOfflineUnsupported, removeEdge],
); );
const splitEdgeAtExistingNodeMut = useMutation( const splitEdgeAtExistingNodeMut = useMutation(
@@ -500,14 +730,18 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
if (edgeList === undefined || nodeList === undefined) return; 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; if (!removed) return;
const t1 = `${OPTIMISTIC_EDGE_PREFIX}s1_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">; 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 t2 = `${OPTIMISTIC_EDGE_PREFIX}s2_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` as Id<"edges">;
const now = Date.now(); 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( nextEdges.push(
{ {
_id: t1, _id: t1,
@@ -536,7 +770,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
localStore.setQuery( localStore.setQuery(
api.nodes.list, api.nodes.list,
{ canvasId: args.canvasId }, { canvasId: args.canvasId },
nodeList.map((n) => nodeList.map((n: Doc<"nodes">) =>
n._id === args.middleNodeId n._id === args.middleNodeId
? { ? {
...n, ...n,
@@ -549,6 +783,17 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
}); });
const runSplitEdgeAtExistingNodeMutation = useCallback(
async (args: Parameters<typeof splitEdgeAtExistingNodeMut>[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. */ /** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
string | null string | null
@@ -586,7 +831,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
resolvedRealIdByClientRequestRef.current.delete(clientRequestId); resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
try { try {
await splitEdgeAtExistingNodeMut({ await runSplitEdgeAtExistingNodeMutation({
canvasId, canvasId,
splitEdgeId: splitPayload.intersectedEdgeId, splitEdgeId: splitPayload.intersectedEdgeId,
middleNodeId: realId, middleNodeId: realId,
@@ -642,7 +887,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (splitPayload) { if (splitPayload) {
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId); pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
try { try {
await splitEdgeAtExistingNodeMut({ await runSplitEdgeAtExistingNodeMutation({
canvasId, canvasId,
splitEdgeId: splitPayload.intersectedEdgeId, splitEdgeId: splitPayload.intersectedEdgeId,
middleNodeId: r, middleNodeId: r,
@@ -672,12 +917,14 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}); });
} }
}, },
[canvasId, runMoveNodeMutation, splitEdgeAtExistingNodeMut], [canvasId, runMoveNodeMutation, runSplitEdgeAtExistingNodeMutation],
); );
// ─── Lokaler State (für flüssiges Dragging) ─────────────────── // ─── Lokaler State (für flüssiges Dragging) ───────────────────
const [nodes, setNodes] = useState<RFNode[]>([]); const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]); const [edges, setEdges] = useState<RFEdge[]>([]);
const nodesRef = useRef<RFNode[]>(nodes);
nodesRef.current = nodes;
const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false); const [hasHydratedLocalSnapshot, setHasHydratedLocalSnapshot] = useState(false);
/** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */ /** Erzwingt Edge-Merge nach Mutation, falls clientRequestId→realId-Ref erst im Promise gesetzt wird. */
const [edgeSyncNonce, setEdgeSyncNonce] = useState(0); const [edgeSyncNonce, setEdgeSyncNonce] = useState(0);
@@ -788,6 +1035,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({ const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
canvasId, canvasId,
isOffline: !isSyncOnline,
nodes, nodes,
edges, edges,
deletingNodeIds, deletingNodeIds,
@@ -816,7 +1064,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current; const prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current;
const currentConvexIdList = const currentConvexIdList =
convexNodes !== undefined convexNodes !== undefined
? convexNodes.map((n) => n._id as string) ? convexNodes.map((n: Doc<"nodes">) => n._id as string)
: []; : [];
const currentConvexIdSet = new Set(currentConvexIdList); const currentConvexIdSet = new Set(currentConvexIdList);
const newlyAppearedIds: string[] = []; const newlyAppearedIds: string[] = [];
@@ -827,10 +1075,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const tempEdges = prev.filter((e) => e.className === "temp"); const tempEdges = prev.filter((e) => e.className === "temp");
const sourceTypeByNodeId = const sourceTypeByNodeId =
convexNodes !== undefined 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; : undefined;
const glowMode = resolvedTheme === "dark" ? "dark" : "light"; const glowMode = resolvedTheme === "dark" ? "dark" : "light";
const mapped = convexEdges.map((edge) => const mapped = convexEdges.map((edge: Doc<"edges">) =>
sourceTypeByNodeId sourceTypeByNodeId
? convexEdgeToRFWithSourceGlow( ? convexEdgeToRFWithSourceGlow(
edge, edge,
@@ -843,14 +1093,27 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature));
const convexNodeIds = const convexNodeIds =
convexNodes !== undefined convexNodes !== undefined
? new Set(convexNodes.map((n) => n._id as string)) ? new Set(convexNodes.map((n: Doc<"nodes">) => n._id as string))
: null; : null;
const realIdByClientRequest = resolvedRealIdByClientRequestRef.current; 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 => { const resolveEndpoint = (nodeId: string): string => {
if (!isOptimisticNodeId(nodeId)) return nodeId; if (!isOptimisticNodeId(nodeId)) return nodeId;
const cr = clientRequestIdFromOptimisticNodeId(nodeId); const cr = clientRequestIdFromOptimisticNodeId(nodeId);
if (!cr) return nodeId; if (!cr) return nodeId;
if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) {
return nodeId;
}
const real = realIdByClientRequest.get(cr); const real = realIdByClientRequest.get(cr);
return real !== undefined ? (real as string) : nodeId; return real !== undefined ? (real as string) : nodeId;
}; };
@@ -862,6 +1125,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
): string => { ): string => {
const base = resolveEndpoint(nodeId); const base = resolveEndpoint(nodeId);
if (!isOptimisticNodeId(base)) return base; if (!isOptimisticNodeId(base)) return base;
if (isAnyNodeDragging) return base;
const nodeCr = clientRequestIdFromOptimisticNodeId(base); const nodeCr = clientRequestIdFromOptimisticNodeId(base);
if (nodeCr === null) return base; if (nodeCr === null) return base;
const edgeCr = clientRequestIdFromOptimisticEdgeId(edge.id); const edgeCr = clientRequestIdFromOptimisticEdgeId(edge.id);
@@ -877,6 +1141,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}; };
const endpointUsable = (nodeId: string): boolean => { const endpointUsable = (nodeId: string): boolean => {
if (isAnyNodeDragging && localHasOptimisticNode(nodeId)) return true;
const resolved = resolveEndpoint(nodeId); const resolved = resolveEndpoint(nodeId);
if (convexNodeIds?.has(resolved)) return true; if (convexNodeIds?.has(resolved)) return true;
if (convexNodeIds?.has(nodeId)) return true; if (convexNodeIds?.has(nodeId)) return true;
@@ -950,9 +1215,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
if (realId === undefined) continue; if (realId === undefined) continue;
const nodePresent = const nodePresent =
convexNodes !== undefined && convexNodes !== undefined &&
convexNodes.some((n) => n._id === realId); convexNodes.some((n: Doc<"nodes">) => n._id === realId);
const edgeTouchesNewNode = convexEdges.some( const edgeTouchesNewNode = convexEdges.some(
(e) => e.sourceNodeId === realId || e.targetNodeId === realId, (e: Doc<"edges">) =>
e.sourceNodeId === realId || e.targetNodeId === realId,
); );
if (nodePresent && edgeTouchesNewNode) { if (nodePresent && edgeTouchesNewNode) {
pendingConnectionCreatesRef.current.delete(cr); pendingConnectionCreatesRef.current.delete(cr);
@@ -978,22 +1244,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
Boolean((n as { dragging?: boolean }).dragging), Boolean((n as { dragging?: boolean }).dragging),
); );
if (isDragging.current || anyRfNodeDragging) { if (isDragging.current || anyRfNodeDragging) {
const needsOptimisticHandoff = previousNodes.some((n) => { // Kritisch für UX: Kein optimistic->real-ID-Handoff während aktivem Drag.
const cr = clientRequestIdFromOptimisticNodeId(n.id); // Sonst kann React Flow den Drag verlieren ("Node klebt"), sobald der
return ( // Server-Create zurückkommt und die ID im laufenden Pointer-Stream wechselt.
cr !== null &&
resolvedRealIdByClientRequestRef.current.has(cr)
);
});
if (!needsOptimisticHandoff) {
return previousNodes; return previousNodes;
} }
}
const prevDataById = new Map( const prevDataById = new Map(
previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]), previousNodes.map((node) => [node.id, node.data as Record<string, unknown>]),
); );
const enriched = convexNodes.map((node) => const enriched = convexNodes.map((node: Doc<"nodes">) =>
convexNodeDocWithMergedStorageUrl( convexNodeDocWithMergedStorageUrl(
node, node,
storageUrlsById, storageUrlsById,
@@ -1227,9 +1487,16 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// ─── Drag Stop → Commit zu Convex ───────────────────────────── // ─── Drag Stop → Commit zu Convex ─────────────────────────────
const onNodeDragStop = useCallback( const onNodeDragStop = useCallback(
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => { (_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
const primaryNode = (node as RFNode | undefined) ?? draggedNodes[0];
const intersectedEdgeId = overlappedEdgeRef.current; const intersectedEdgeId = overlappedEdgeRef.current;
void (async () => { void (async () => {
if (!primaryNode) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
isDragging.current = false;
return;
}
try { try {
const intersectedEdge = intersectedEdgeId const intersectedEdge = intersectedEdgeId
? edges.find( ? edges.find(
@@ -1238,12 +1505,12 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
) )
: undefined; : undefined;
const splitHandles = NODE_HANDLE_MAP[node.type ?? ""]; const splitHandles = NODE_HANDLE_MAP[primaryNode.type ?? ""];
const splitEligible = const splitEligible =
intersectedEdge !== undefined && intersectedEdge !== undefined &&
splitHandles !== undefined && splitHandles !== undefined &&
intersectedEdge.source !== node.id && intersectedEdge.source !== primaryNode.id &&
intersectedEdge.target !== node.id && intersectedEdge.target !== primaryNode.id &&
hasHandleKey(splitHandles, "source") && hasHandleKey(splitHandles, "source") &&
hasHandleKey(splitHandles, "target"); hasHandleKey(splitHandles, "target");
@@ -1273,8 +1540,8 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
return; return;
} }
const multiCid = clientRequestIdFromOptimisticNodeId(node.id); const multiCid = clientRequestIdFromOptimisticNodeId(primaryNode.id);
let middleId = node.id as Id<"nodes">; let middleId = primaryNode.id as Id<"nodes">;
if (multiCid) { if (multiCid) {
const r = resolvedRealIdByClientRequestRef.current.get(multiCid); const r = resolvedRealIdByClientRequestRef.current.get(multiCid);
if (!r) { if (!r) {
@@ -1290,15 +1557,15 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
), ),
middleSourceHandle: normalizeHandle(splitHandles.source), middleSourceHandle: normalizeHandle(splitHandles.source),
middleTargetHandle: normalizeHandle(splitHandles.target), middleTargetHandle: normalizeHandle(splitHandles.target),
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
return; return;
} }
middleId = r; middleId = r;
} }
await splitEdgeAtExistingNodeMut({ await runSplitEdgeAtExistingNodeMutation({
canvasId, canvasId,
splitEdgeId: intersectedEdge.id as Id<"edges">, splitEdgeId: intersectedEdge.id as Id<"edges">,
middleNodeId: middleId, middleNodeId: middleId,
@@ -1311,31 +1578,31 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
if (!splitEligible || !intersectedEdge) { if (!splitEligible || !intersectedEdge) {
const cidSingle = clientRequestIdFromOptimisticNodeId(node.id); const cidSingle = clientRequestIdFromOptimisticNodeId(primaryNode.id);
if (cidSingle) { if (cidSingle) {
pendingMoveAfterCreateRef.current.set(cidSingle, { pendingMoveAfterCreateRef.current.set(cidSingle, {
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
await syncPendingMoveForClientRequest(cidSingle); await syncPendingMoveForClientRequest(cidSingle);
} else { } else {
await runMoveNodeMutation({ await runMoveNodeMutation({
nodeId: node.id as Id<"nodes">, nodeId: primaryNode.id as Id<"nodes">,
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
} }
return; return;
} }
const singleCid = clientRequestIdFromOptimisticNodeId(node.id); const singleCid = clientRequestIdFromOptimisticNodeId(primaryNode.id);
if (singleCid) { if (singleCid) {
const resolvedSingle = const resolvedSingle =
resolvedRealIdByClientRequestRef.current.get(singleCid); resolvedRealIdByClientRequestRef.current.get(singleCid);
if (!resolvedSingle) { if (!resolvedSingle) {
pendingMoveAfterCreateRef.current.set(singleCid, { pendingMoveAfterCreateRef.current.set(singleCid, {
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
pendingEdgeSplitByClientRequestRef.current.set(singleCid, { pendingEdgeSplitByClientRequestRef.current.set(singleCid, {
intersectedEdgeId: intersectedEdge.id as Id<"edges">, intersectedEdgeId: intersectedEdge.id as Id<"edges">,
@@ -1349,13 +1616,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
), ),
middleSourceHandle: normalizeHandle(splitHandles.source), middleSourceHandle: normalizeHandle(splitHandles.source),
middleTargetHandle: normalizeHandle(splitHandles.target), middleTargetHandle: normalizeHandle(splitHandles.target),
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
await syncPendingMoveForClientRequest(singleCid); await syncPendingMoveForClientRequest(singleCid);
return; return;
} }
await splitEdgeAtExistingNodeMut({ await runSplitEdgeAtExistingNodeMutation({
canvasId, canvasId,
splitEdgeId: intersectedEdge.id as Id<"edges">, splitEdgeId: intersectedEdge.id as Id<"edges">,
middleNodeId: resolvedSingle, middleNodeId: resolvedSingle,
@@ -1363,29 +1630,29 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle),
newNodeSourceHandle: normalizeHandle(splitHandles.source), newNodeSourceHandle: normalizeHandle(splitHandles.source),
newNodeTargetHandle: normalizeHandle(splitHandles.target), newNodeTargetHandle: normalizeHandle(splitHandles.target),
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
pendingMoveAfterCreateRef.current.delete(singleCid); pendingMoveAfterCreateRef.current.delete(singleCid);
return; return;
} }
await splitEdgeAtExistingNodeMut({ await runSplitEdgeAtExistingNodeMutation({
canvasId, canvasId,
splitEdgeId: intersectedEdge.id as Id<"edges">, splitEdgeId: intersectedEdge.id as Id<"edges">,
middleNodeId: node.id as Id<"nodes">, middleNodeId: primaryNode.id as Id<"nodes">,
splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle), splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle), splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle),
newNodeSourceHandle: normalizeHandle(splitHandles.source), newNodeSourceHandle: normalizeHandle(splitHandles.source),
newNodeTargetHandle: normalizeHandle(splitHandles.target), newNodeTargetHandle: normalizeHandle(splitHandles.target),
positionX: node.position.x, positionX: primaryNode.position.x,
positionY: node.position.y, positionY: primaryNode.position.y,
}); });
} catch (error) { } catch (error) {
console.error("[Canvas edge intersection split failed]", { console.error("[Canvas edge intersection split failed]", {
canvasId, canvasId,
nodeId: node.id, nodeId: primaryNode?.id ?? null,
nodeType: node.type, nodeType: primaryNode?.type ?? null,
intersectedEdgeId, intersectedEdgeId,
error: String(error), error: String(error),
}); });
@@ -1402,7 +1669,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
runBatchMoveNodesMutation, runBatchMoveNodesMutation,
runMoveNodeMutation, runMoveNodeMutation,
setHighlightedIntersectionEdge, setHighlightedIntersectionEdge,
splitEdgeAtExistingNodeMut, runSplitEdgeAtExistingNodeMutation,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
], ],
); );
@@ -1450,6 +1717,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const handleConnectionDropPick = useCallback( const handleConnectionDropPick = useCallback(
(template: CanvasNodeTemplate) => { (template: CanvasNodeTemplate) => {
if (!isSyncOnline) {
notifyOfflineUnsupported("Node mit Verbindung erstellen");
return;
}
const ctx = connectionDropMenuRef.current; const ctx = connectionDropMenuRef.current;
if (!ctx) return; if (!ctx) return;
@@ -1489,7 +1760,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}; };
if (ctx.fromHandleType === "source") { if (ctx.fromHandleType === "source") {
void createNodeWithEdgeFromSource({ void runCreateNodeWithEdgeFromSourceOnlineOnly({
...base, ...base,
sourceNodeId: ctx.fromNodeId, sourceNodeId: ctx.fromNodeId,
sourceHandle: ctx.fromHandleId, sourceHandle: ctx.fromHandleId,
@@ -1508,7 +1779,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
console.error("[Canvas] createNodeWithEdgeFromSource failed", error); console.error("[Canvas] createNodeWithEdgeFromSource failed", error);
}); });
} else { } else {
void createNodeWithEdgeToTarget({ void runCreateNodeWithEdgeToTargetOnlineOnly({
...base, ...base,
targetNodeId: ctx.fromNodeId, targetNodeId: ctx.fromNodeId,
sourceHandle: handles?.source ?? undefined, sourceHandle: handles?.source ?? undefined,
@@ -1530,8 +1801,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}, },
[ [
canvasId, canvasId,
createNodeWithEdgeFromSource, isSyncOnline,
createNodeWithEdgeToTarget, notifyOfflineUnsupported,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
syncPendingMoveForClientRequest, syncPendingMoveForClientRequest,
], ],
); );
@@ -1545,6 +1818,10 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const onDrop = useCallback( const onDrop = useCallback(
async (event: React.DragEvent) => { async (event: React.DragEvent) => {
event.preventDefault(); event.preventDefault();
if (!isSyncOnline) {
notifyOfflineUnsupported("Node erstellen");
return;
}
const rawData = event.dataTransfer.getData( const rawData = event.dataTransfer.getData(
"application/lemonspace-node-type", "application/lemonspace-node-type",
@@ -1578,7 +1855,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const clientRequestId = crypto.randomUUID(); const clientRequestId = crypto.randomUUID();
void createNode({ void runCreateNodeOnlineOnly({
canvasId, canvasId,
type: "image", type: "image",
positionX: position.x, positionX: position.x,
@@ -1642,7 +1919,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}; };
const clientRequestId = crypto.randomUUID(); const clientRequestId = crypto.randomUUID();
void createNode({ void runCreateNodeOnlineOnly({
canvasId, canvasId,
type: nodeType, type: nodeType,
positionX: position.x, 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 ──────────────────────────────────────────── // ─── Loading State ────────────────────────────────────────────
@@ -1678,12 +1976,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
} }
return ( return (
<CanvasSyncProvider value={canvasSyncContextValue}>
<CanvasPlacementProvider <CanvasPlacementProvider
canvasId={canvasId} canvasId={canvasId}
createNode={createNode} createNode={runCreateNodeOnlineOnly}
createNodeWithEdgeSplit={createNodeWithEdgeSplit} createNodeWithEdgeSplit={runCreateNodeWithEdgeSplitOnlineOnly}
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource} createNodeWithEdgeFromSource={runCreateNodeWithEdgeFromSourceOnlineOnly}
createNodeWithEdgeToTarget={createNodeWithEdgeToTarget} createNodeWithEdgeToTarget={runCreateNodeWithEdgeToTargetOnlineOnly}
onCreateNodeSettled={({ clientRequestId, realId }) => { onCreateNodeSettled={({ clientRequestId, realId }) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch( void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => { (error: unknown) => {
@@ -1789,6 +2088,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
</div> </div>
</AssetBrowserTargetContext.Provider> </AssetBrowserTargetContext.Provider>
</CanvasPlacementProvider> </CanvasPlacementProvider>
</CanvasSyncProvider>
); );
} }

View File

@@ -12,6 +12,7 @@ import { classifyError, type AiErrorCategory } from "@/lib/ai-errors";
import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats"; import { DEFAULT_ASPECT_RATIO } from "@/lib/image-formats";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages"; import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
@@ -60,6 +61,7 @@ export default function AiImageNode({
}: NodeProps<AiImageNode>) { }: NodeProps<AiImageNode>) {
const nodeData = data as AiImageNodeData; const nodeData = data as AiImageNodeData;
const { getEdges, getNode } = useReactFlow(); const { getEdges, getNode } = useReactFlow();
const { status: syncStatus } = useCanvasSync();
const router = useRouter(); const router = useRouter();
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
@@ -84,6 +86,13 @@ export default function AiImageNode({
const handleRegenerate = useCallback(async () => { const handleRegenerate = useCallback(async () => {
if (isLoading) return; if (isLoading) return;
if (syncStatus.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"KI-Generierung benötigt eine aktive Verbindung.",
);
return;
}
setLocalError(null); setLocalError(null);
setIsGenerating(true); setIsGenerating(true);
@@ -140,7 +149,7 @@ export default function AiImageNode({
} finally { } finally {
setIsGenerating(false); setIsGenerating(false);
} }
}, [isLoading, nodeData, id, getEdges, getNode, generateImage]); }, [isLoading, syncStatus.isOffline, nodeData, id, getEdges, getNode, generateImage]);
const modelName = const modelName =
getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI"; getModel(nodeData.model ?? DEFAULT_MODEL_ID)?.name ?? "AI";

View File

@@ -9,7 +9,6 @@ import {
type MouseEvent, type MouseEvent,
} from "react"; } from "react";
import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react"; import { Handle, Position, useStore, type Node, type NodeProps } from "@xyflow/react";
import { useMutation } from "convex/react";
import { ExternalLink, ImageIcon } from "lucide-react"; import { ExternalLink, ImageIcon } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { import {
@@ -17,11 +16,11 @@ import {
useAssetBrowserTarget, useAssetBrowserTarget,
type AssetBrowserSessionState, type AssetBrowserSessionState,
} from "@/components/canvas/asset-browser-panel"; } from "@/components/canvas/asset-browser-panel";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveMediaAspectRatio } from "@/lib/canvas-utils"; import { resolveMediaAspectRatio } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type AssetNodeData = { type AssetNodeData = {
assetId?: number; assetId?: number;
@@ -55,7 +54,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
page: 1, page: 1,
totalPages: 1, totalPages: 1,
}); });
const resizeNode = useMutation(api.nodes.resize); const { queueNodeResize } = useCanvasSync();
const edges = useStore((s) => s.edges); const edges = useStore((s) => s.edges);
const nodes = useStore((s) => s.nodes); const nodes = useStore((s) => s.nodes);
@@ -124,7 +123,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
} }
hasAutoSizedRef.current = true; hasAutoSizedRef.current = true;
void resizeNode({ void queueNodeResize({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
width: targetSize.width, width: targetSize.width,
height: targetSize.height, height: targetSize.height,
@@ -136,7 +135,7 @@ export default function AssetNode({ id, data, selected, width, height }: NodePro
hasAsset, hasAsset,
height, height,
id, id,
resizeNode, queueNodeResize,
width, width,
]); ]);

View File

@@ -2,7 +2,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/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 { Download, Loader2 } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; 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 BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages"; import { msg } from "@/lib/toast-messages";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
interface FrameNodeData { interface FrameNodeData {
label?: string; label?: string;
@@ -19,7 +20,7 @@ interface FrameNodeData {
export default function FrameNode({ id, data, selected, width, height }: NodeProps) { export default function FrameNode({ id, data, selected, width, height }: NodeProps) {
const nodeData = data as FrameNodeData; const nodeData = data as FrameNodeData;
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate, status } = useCanvasSync();
const exportFrame = useAction(api.export.exportFrame); const exportFrame = useAction(api.export.exportFrame);
const [label, setLabel] = useState(nodeData.label ?? "Frame"); 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<string | null>(null); const [exportError, setExportError] = useState<string | null>(null);
const debouncedSave = useDebouncedCallback((value: string) => { 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); }, 500);
const handleLabelChange = useCallback( const handleLabelChange = useCallback(
@@ -40,6 +44,10 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
const handleExport = useCallback(async () => { const handleExport = useCallback(async () => {
if (isExporting) return; if (isExporting) return;
if (status.isOffline) {
toast.warning("Offline aktuell nicht unterstützt", "Export benötigt eine aktive Verbindung.");
return;
}
setIsExporting(true); setIsExporting(true);
setExportError(null); setExportError(null);
@@ -67,7 +75,7 @@ export default function FrameNode({ id, data, selected, width, height }: NodePro
} finally { } finally {
setIsExporting(false); setIsExporting(false);
} }
}, [exportFrame, id, isExporting, label]); }, [exportFrame, id, isExporting, label, status.isOffline]);
const frameW = Math.round(width ?? 400); const frameW = Math.round(width ?? 400);
const frameH = Math.round(height ?? 300); const frameH = Math.round(height ?? 300);

View File

@@ -2,10 +2,9 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/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 type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type GroupNodeData = { type GroupNodeData = {
label?: string; label?: string;
@@ -16,7 +15,7 @@ type GroupNodeData = {
export type GroupNode = Node<GroupNodeData, "group">; export type GroupNode = Node<GroupNodeData, "group">;
export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>) { export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>) {
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate } = useCanvasSync();
const [label, setLabel] = useState(data.label ?? "Gruppe"); const [label, setLabel] = useState(data.label ?? "Gruppe");
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -30,7 +29,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsEditing(false); setIsEditing(false);
if (label !== data.label) { if (label !== data.label) {
updateData({ void queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
data: { data: {
...data, ...data,
@@ -40,7 +39,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
}, },
}); });
} }
}, [label, data, id, updateData]); }, [label, data, id, queueNodeDataUpdate]);
return ( return (
<BaseNodeWrapper <BaseNodeWrapper

View File

@@ -9,13 +9,14 @@ import {
type DragEvent, type DragEvent,
} from "react"; } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { msg } from "@/lib/toast-messages"; import { msg } from "@/lib/toast-messages";
import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { computeMediaNodeSize } from "@/lib/canvas-utils";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useMutation } from "convex/react";
const ALLOWED_IMAGE_TYPES = new Set([ const ALLOWED_IMAGE_TYPES = new Set([
"image/png", "image/png",
@@ -73,8 +74,7 @@ export default function ImageNode({
height, height,
}: NodeProps<ImageNode>) { }: NodeProps<ImageNode>) {
const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const generateUploadUrl = useMutation(api.storage.generateUploadUrl);
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const resizeNode = useMutation(api.nodes.resize);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -111,12 +111,12 @@ export default function ImageNode({
} }
hasAutoSizedRef.current = true; hasAutoSizedRef.current = true;
void resizeNode({ void queueNodeResize({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
width: targetSize.width, width: targetSize.width,
height: targetSize.height, height: targetSize.height,
}); });
}, [data.height, data.width, height, id, resizeNode, width]); }, [data.height, data.width, height, id, queueNodeResize, width]);
const uploadFile = useCallback( const uploadFile = useCallback(
async (file: File) => { async (file: File) => {
@@ -134,6 +134,13 @@ export default function ImageNode({
toast.error(title, desc); toast.error(title, desc);
return; return;
} }
if (status.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"Bild-Uploads benötigen eine aktive Verbindung.",
);
return;
}
setIsUploading(true); setIsUploading(true);
@@ -158,7 +165,7 @@ export default function ImageNode({
const { storageId } = (await result.json()) as { storageId: string }; const { storageId } = (await result.json()) as { storageId: string };
await updateData({ await queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
data: { data: {
storageId, storageId,
@@ -174,7 +181,7 @@ export default function ImageNode({
intrinsicHeight: dimensions.height, intrinsicHeight: dimensions.height,
}); });
await resizeNode({ await queueNodeResize({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
width: targetSize.width, width: targetSize.width,
height: targetSize.height, height: targetSize.height,
@@ -192,7 +199,7 @@ export default function ImageNode({
setIsUploading(false); setIsUploading(false);
} }
}, },
[id, generateUploadUrl, resizeNode, updateData] [generateUploadUrl, id, queueNodeDataUpdate, queueNodeResize, status.isOffline]
); );
const handleClick = useCallback(() => { const handleClick = useCallback(() => {

View File

@@ -2,11 +2,10 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect } from "react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/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 type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type NoteNodeData = { type NoteNodeData = {
content?: string; content?: string;
@@ -17,7 +16,7 @@ type NoteNodeData = {
export type NoteNode = Node<NoteNodeData, "note">; export type NoteNode = Node<NoteNodeData, "note">;
export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) { export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate } = useCanvasSync();
const [content, setContent] = useState(data.content ?? ""); const [content, setContent] = useState(data.content ?? "");
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -30,7 +29,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
const saveContent = useDebouncedCallback( const saveContent = useDebouncedCallback(
(newContent: string) => { (newContent: string) => {
updateData({ void queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
data: { data: {
...data, ...data,

View File

@@ -9,12 +9,13 @@ import {
type NodeProps, type NodeProps,
type Node, type Node,
} from "@xyflow/react"; } from "@xyflow/react";
import { useMutation, useAction } from "convex/react"; import { useAction } from "convex/react";
import { useAuthQuery } from "@/hooks/use-auth-query"; import { useAuthQuery } from "@/hooks/use-auth-query";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context"; import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models"; import { DEFAULT_MODEL_ID, getModel } from "@/lib/ai-models";
import { import {
@@ -118,7 +119,7 @@ export default function PromptNode({
const hasEnoughCredits = const hasEnoughCredits =
availableCredits !== null && availableCredits >= creditCost; availableCredits !== null && availableCredits >= creditCost;
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate, status } = useCanvasSync();
const generateImage = useAction(api.ai.generateImage); const generateImage = useAction(api.ai.generateImage);
const { createNodeConnectedFromSource } = useCanvasPlacement(); const { createNodeConnectedFromSource } = useCanvasPlacement();
@@ -127,7 +128,7 @@ export default function PromptNode({
const { _status, _statusMessage, ...rest } = raw; const { _status, _statusMessage, ...rest } = raw;
void _status; void _status;
void _statusMessage; void _statusMessage;
updateData({ void queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
data: { data: {
...rest, ...rest,
@@ -156,6 +157,13 @@ export default function PromptNode({
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!effectivePrompt.trim() || isGenerating) return; 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) { if (availableCredits !== null && !hasEnoughCredits) {
const { title, desc } = msg.ai.insufficientCredits( const { title, desc } = msg.ai.insufficientCredits(
@@ -291,6 +299,7 @@ export default function PromptNode({
availableCredits, availableCredits,
hasEnoughCredits, hasEnoughCredits,
router, router,
status.isOffline,
]); ]);
return ( return (

View File

@@ -8,11 +8,10 @@ import {
type NodeProps, type NodeProps,
type Node, type Node,
} from "@xyflow/react"; } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type TextNodeData = { type TextNodeData = {
content?: string; content?: string;
@@ -24,7 +23,7 @@ export type TextNode = Node<TextNodeData, "text">;
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) { export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
const { setNodes } = useReactFlow(); const { setNodes } = useReactFlow();
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate } = useCanvasSync();
const [content, setContent] = useState(data.content ?? ""); const [content, setContent] = useState(data.content ?? "");
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -39,7 +38,7 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
// Debounced Save — 500ms nach letztem Tastendruck // Debounced Save — 500ms nach letztem Tastendruck
const saveContent = useDebouncedCallback( const saveContent = useDebouncedCallback(
(newContent: string) => { (newContent: string) => {
updateData({ void queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
data: { data: {
...data, ...data,

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Handle, Position, useStore, type NodeProps } from "@xyflow/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 { Play } from "lucide-react";
import BaseNodeWrapper from "./base-node-wrapper"; import BaseNodeWrapper from "./base-node-wrapper";
import { import {
@@ -11,6 +11,7 @@ import {
} from "@/components/canvas/video-browser-panel"; } from "@/components/canvas/video-browser-panel";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type VideoNodeData = { type VideoNodeData = {
canvasId?: string; canvasId?: string;
@@ -50,8 +51,7 @@ export default function VideoNode({
page: 1, page: 1,
totalPages: 1, totalPages: 1,
}); });
const resizeNode = useMutation(api.nodes.resize); const { queueNodeDataUpdate, queueNodeResize } = useCanvasSync();
const updateData = useMutation(api.nodes.updateData);
const refreshPexelsPlayback = useAction(api.pexels.getVideoByPexelsId); const refreshPexelsPlayback = useAction(api.pexels.getVideoByPexelsId);
const edges = useStore((s) => s.edges); const edges = useStore((s) => s.edges);
@@ -95,7 +95,7 @@ export default function VideoNode({
void (async () => { void (async () => {
try { try {
const fresh = await refreshPexelsPlayback({ pexelsId }); const fresh = await refreshPexelsPlayback({ pexelsId });
await updateData({ await queueNodeDataUpdate({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
data: { data: {
...d, ...d,
@@ -109,7 +109,7 @@ export default function VideoNode({
playbackRefreshAttempted.current = false; playbackRefreshAttempted.current = false;
} }
})(); })();
}, [d, id, refreshPexelsPlayback, updateData]); }, [d, id, queueNodeDataUpdate, refreshPexelsPlayback]);
useEffect(() => { useEffect(() => {
if (!hasVideo) return; if (!hasVideo) return;
@@ -134,12 +134,12 @@ export default function VideoNode({
const targetWidth = 320; const targetWidth = 320;
const targetHeight = Math.round(targetWidth / aspectRatio); const targetHeight = Math.round(targetWidth / aspectRatio);
void resizeNode({ void queueNodeResize({
nodeId: id as Id<"nodes">, nodeId: id as Id<"nodes">,
width: targetWidth, width: targetWidth,
height: targetHeight, 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; const showPreview = hasVideo && d.thumbnailUrl;

View File

@@ -10,7 +10,7 @@ import {
type PointerEvent, type PointerEvent,
} from "react"; } from "react";
import { createPortal } from "react-dom"; 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 { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react";
import { api } from "@/convex/_generated/api"; import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel"; 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 type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types";
import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types"; import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types";
import { toast } from "@/lib/toast"; import { toast } from "@/lib/toast";
import { useCanvasSync } from "@/components/canvas/canvas-sync-context";
type Orientation = "" | "landscape" | "portrait" | "square"; type Orientation = "" | "landscape" | "portrait" | "square";
type DurationFilter = "all" | "short" | "medium" | "long"; type DurationFilter = "all" | "short" | "medium" | "long";
@@ -82,8 +83,7 @@ export function VideoBrowserPanel({
const searchVideos = useAction(api.pexels.searchVideos); const searchVideos = useAction(api.pexels.searchVideos);
const popularVideos = useAction(api.pexels.popularVideos); const popularVideos = useAction(api.pexels.popularVideos);
const updateData = useMutation(api.nodes.updateData); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync();
const resizeNode = useMutation(api.nodes.resize);
const shouldSkipInitialSearchRef = useRef( const shouldSkipInitialSearchRef = useRef(
Boolean(initialState?.results?.length), Boolean(initialState?.results?.length),
); );
@@ -197,6 +197,13 @@ export function VideoBrowserPanel({
const handleSelect = useCallback( const handleSelect = useCallback(
async (video: PexelsVideo) => { async (video: PexelsVideo) => {
if (isSelecting) return; if (isSelecting) return;
if (status.isOffline) {
toast.warning(
"Offline aktuell nicht unterstützt",
"Video-Auswahl benötigt eine aktive Verbindung.",
);
return;
}
setSelectingVideoId(video.id); setSelectingVideoId(video.id);
let file: PexelsVideoFile; let file: PexelsVideoFile;
try { try {
@@ -209,7 +216,7 @@ export function VideoBrowserPanel({
return; return;
} }
try { try {
await updateData({ await queueNodeDataUpdate({
nodeId: nodeId as Id<"nodes">, nodeId: nodeId as Id<"nodes">,
data: { data: {
pexelsId: video.id, pexelsId: video.id,
@@ -234,7 +241,7 @@ export function VideoBrowserPanel({
: 16 / 9; : 16 / 9;
const targetWidth = 320; const targetWidth = 320;
const targetHeight = Math.round(targetWidth / aspectRatio); const targetHeight = Math.round(targetWidth / aspectRatio);
await resizeNode({ await queueNodeResize({
nodeId: nodeId as Id<"nodes">, nodeId: nodeId as Id<"nodes">,
width: targetWidth, width: targetWidth,
height: targetHeight, height: targetHeight,
@@ -246,7 +253,7 @@ export function VideoBrowserPanel({
setSelectingVideoId(null); setSelectingVideoId(null);
} }
}, },
[canvasId, isSelecting, nodeId, onClose, resizeNode, updateData], [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline],
); );
const handlePreviousPage = useCallback(() => { const handlePreviousPage = useCallback(() => {

View File

@@ -151,6 +151,7 @@ export function enqueueCanvasOp(
enqueuedAt: op.enqueuedAt ?? Date.now(), enqueuedAt: op.enqueuedAt ?? Date.now(),
}; };
const payload = readOpsPayload(canvasId); const payload = readOpsPayload(canvasId);
payload.ops = payload.ops.filter((candidate) => candidate.id !== entry.id);
payload.ops.push(entry); payload.ops.push(entry);
payload.updatedAt = Date.now(); payload.updatedAt = Date.now();
writePayload(opsKey(canvasId), payload); writePayload(opsKey(canvasId), payload);
@@ -166,6 +167,17 @@ export function resolveCanvasOp(canvasId: string, opId: string): void {
writePayload(opsKey(canvasId), payload); 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[] { export function readCanvasOps(canvasId: string): CanvasPendingOp[] {
return readOpsPayload(canvasId).ops; return readOpsPayload(canvasId).ops;
} }

420
lib/canvas-op-queue.ts Normal file
View File

@@ -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<TType extends CanvasSyncOpType> = Extract<
CanvasSyncOp,
{ type: TType }
>;
type JsonRecord = Record<string, unknown>;
type EnqueueInput<TType extends CanvasSyncOpType> = {
id: string;
canvasId: string;
type: TType;
payload: CanvasSyncOpPayloadByType[TType];
now?: number;
};
let dbPromise: Promise<IDBDatabase | null> | 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<IDBDatabase | null> {
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<void> {
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<T>(req: IDBRequest<T>): Promise<T> {
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<TType extends CanvasSyncOpType>(
input: EnqueueInput<TType>,
): CanvasSyncOpFor<TType> {
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<TType>;
}
function coalescingNodeId(
op: Pick<CanvasSyncOp, "type" | "payload">,
): 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<CanvasSyncOp[]> {
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<number> {
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<TType extends CanvasSyncOpType>(
input: EnqueueInput<TType>,
): 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<void> {
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<void> {
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<void>((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<string[]> {
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;
}