Implement local-first canvas sync and fix drag edge stability
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -8,6 +8,9 @@ Der Canvas ist das Herzstück von LemonSpace. Er basiert auf `@xyflow/react` (Re
|
||||
|
||||
```
|
||||
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
|
||||
├── <ReactFlowProvider>
|
||||
│ └── <CanvasInner> ← Haupt-Komponente (~1800 Zeilen)
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string | null>(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<HTMLDivElement | null>(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(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getNodeDeleteBlockReason, isOptimisticEdgeId } from "./canvas-helpers";
|
||||
|
||||
type UseCanvasDeleteHandlersParams = {
|
||||
canvasId: Id<"canvases">;
|
||||
isOffline: boolean;
|
||||
nodes: RFNode[];
|
||||
edges: RFEdge[];
|
||||
deletingNodeIds: MutableRefObject<Set<string>>;
|
||||
@@ -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(
|
||||
|
||||
@@ -7,18 +7,12 @@ 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",
|
||||
{
|
||||
type CreateNodeArgs = {
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
positionX: number;
|
||||
@@ -29,16 +23,9 @@ type CreateNodeMutation = ReactMutation<
|
||||
parentId?: Id<"nodes">;
|
||||
zIndex?: number;
|
||||
clientRequestId?: string;
|
||||
},
|
||||
Id<"nodes">
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
type CreateNodeWithEdgeSplitMutation = ReactMutation<
|
||||
FunctionReference<
|
||||
"mutation",
|
||||
"public",
|
||||
{
|
||||
type CreateNodeWithEdgeSplitArgs = {
|
||||
canvasId: Id<"canvases">;
|
||||
type: string;
|
||||
positionX: number;
|
||||
@@ -53,56 +40,30 @@ type CreateNodeWithEdgeSplitMutation = ReactMutation<
|
||||
newNodeSourceHandle?: string;
|
||||
splitSourceHandle?: string;
|
||||
splitTargetHandle?: string;
|
||||
},
|
||||
Id<"nodes">
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
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;
|
||||
type CreateNodeWithEdgeFromSourceArgs = CreateNodeArgs & {
|
||||
sourceNodeId: Id<"nodes">;
|
||||
sourceHandle?: string;
|
||||
targetHandle?: string;
|
||||
},
|
||||
Id<"nodes">
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
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;
|
||||
type CreateNodeWithEdgeToTargetArgs = CreateNodeArgs & {
|
||||
targetNodeId: Id<"nodes">;
|
||||
sourceHandle?: 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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
43
components/canvas/canvas-sync-context.tsx
Normal file
43
components/canvas/canvas-sync-context.tsx
Normal 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;
|
||||
}
|
||||
@@ -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<string, { positionX: number; positionY: number }>(),
|
||||
);
|
||||
@@ -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<string>(args.nodeIds.map((id) => id as string));
|
||||
const removeSet = new Set<string>(
|
||||
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<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(
|
||||
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<typeof batchMoveNodes>[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<typeof batchRemoveNodes>[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<typeof createEdge>[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<typeof removeEdge>[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<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. */
|
||||
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<RFNode[]>([]);
|
||||
const [edges, setEdges] = useState<RFEdge[]>([]);
|
||||
const nodesRef = useRef<RFNode[]>(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) {
|
||||
// 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<string, unknown>]),
|
||||
);
|
||||
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,12 +1976,13 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<CanvasSyncProvider value={canvasSyncContextValue}>
|
||||
<CanvasPlacementProvider
|
||||
canvasId={canvasId}
|
||||
createNode={createNode}
|
||||
createNodeWithEdgeSplit={createNodeWithEdgeSplit}
|
||||
createNodeWithEdgeFromSource={createNodeWithEdgeFromSource}
|
||||
createNodeWithEdgeToTarget={createNodeWithEdgeToTarget}
|
||||
createNode={runCreateNodeOnlineOnly}
|
||||
createNodeWithEdgeSplit={runCreateNodeWithEdgeSplitOnlineOnly}
|
||||
createNodeWithEdgeFromSource={runCreateNodeWithEdgeFromSourceOnlineOnly}
|
||||
createNodeWithEdgeToTarget={runCreateNodeWithEdgeToTargetOnlineOnly}
|
||||
onCreateNodeSettled={({ clientRequestId, realId }) => {
|
||||
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
|
||||
(error: unknown) => {
|
||||
@@ -1789,6 +2088,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
|
||||
</div>
|
||||
</AssetBrowserTargetContext.Provider>
|
||||
</CanvasPlacementProvider>
|
||||
</CanvasSyncProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AiImageNode>) {
|
||||
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";
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<string | null>(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);
|
||||
|
||||
@@ -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<GroupNodeData, "group">;
|
||||
|
||||
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 [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -30,7 +29,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
|
||||
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<GroupNode>)
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [label, data, id, updateData]);
|
||||
}, [label, data, id, queueNodeDataUpdate]);
|
||||
|
||||
return (
|
||||
<BaseNodeWrapper
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
type DragEvent,
|
||||
} 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 { toast } from "@/lib/toast";
|
||||
import { msg } from "@/lib/toast-messages";
|
||||
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([
|
||||
"image/png",
|
||||
@@ -73,8 +74,7 @@ export default function ImageNode({
|
||||
height,
|
||||
}: NodeProps<ImageNode>) {
|
||||
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<HTMLInputElement>(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(() => {
|
||||
|
||||
@@ -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<NoteNodeData, "note">;
|
||||
|
||||
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 [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
@@ -30,7 +29,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
|
||||
|
||||
const saveContent = useDebouncedCallback(
|
||||
(newContent: string) => {
|
||||
updateData({
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<TextNodeData, "text">;
|
||||
|
||||
export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
|
||||
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<TextNode>) {
|
||||
// Debounced Save — 500ms nach letztem Tastendruck
|
||||
const saveContent = useDebouncedCallback(
|
||||
(newContent: string) => {
|
||||
updateData({
|
||||
void queueNodeDataUpdate({
|
||||
nodeId: id as Id<"nodes">,
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
420
lib/canvas-op-queue.ts
Normal file
420
lib/canvas-op-queue.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user