Files
lemonspace_app/components/canvas/canvas.tsx

2876 lines
98 KiB
TypeScript

"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from "react";
import { useTheme } from "next-themes";
import { useTranslations } from "next-intl";
import {
ReactFlow,
ReactFlowProvider,
Background,
Controls,
MiniMap,
applyNodeChanges,
applyEdgeChanges,
useReactFlow,
type Node as RFNode,
type Edge as RFEdge,
type NodeChange,
type EdgeChange,
type Connection,
type OnConnectEnd,
BackgroundVariant,
} from "@xyflow/react";
import { cn } from "@/lib/utils";
import "@xyflow/react/dist/style.css";
import { toast } from "@/lib/toast";
import {
CANVAS_NODE_DND_MIME,
type CanvasConnectionValidationReason,
validateCanvasConnectionPolicy,
} from "@/lib/canvas-connection-policy";
import { showCanvasConnectionRejectedToast } from "@/lib/toast-messages";
import {
dropCanvasOpsByClientRequestIds,
dropCanvasOpsByEdgeIds,
dropCanvasOpsByNodeIds,
enqueueCanvasOp,
remapCanvasOpNodeId,
resolveCanvasOp,
resolveCanvasOps,
} from "@/lib/canvas-local-persistence";
import {
ackCanvasSyncOp,
type CanvasSyncOpPayloadByType,
countCanvasSyncOps,
dropCanvasSyncOpsByClientRequestIds,
dropCanvasSyncOpsByEdgeIds,
dropCanvasSyncOpsByNodeIds,
dropExpiredCanvasSyncOps,
enqueueCanvasSyncOp,
listCanvasSyncOps,
markCanvasSyncOpFailed,
remapCanvasSyncNodeId,
} from "@/lib/canvas-op-queue";
import {
useConvexConnectionState,
useMutation,
} from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Doc, Id } from "@/convex/_generated/dataModel";
import {
isAdjustmentPresetNodeType,
isCanvasNodeType,
type CanvasNodeType,
} from "@/lib/canvas-node-types";
import { nodeTypes } from "./node-types";
import {
convexNodeDocWithMergedStorageUrl,
convexNodeToRF,
NODE_DEFAULTS,
NODE_HANDLE_MAP,
} from "@/lib/canvas-utils";
import CanvasToolbar, {
type CanvasNavTool,
} from "@/components/canvas/canvas-toolbar";
import { CanvasAppMenu } from "@/components/canvas/canvas-app-menu";
import { CanvasCommandPalette } from "@/components/canvas/canvas-command-palette";
import {
CanvasConnectionDropMenu,
type ConnectionDropMenuState,
} from "@/components/canvas/canvas-connection-drop-menu";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
import { CanvasPresetsProvider } from "@/components/canvas/canvas-presets-context";
import {
AssetBrowserTargetContext,
type AssetBrowserTargetApi,
} from "@/components/canvas/asset-browser-panel";
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import {
CANVAS_MIN_ZOOM,
clientRequestIdFromOptimisticEdgeId,
clientRequestIdFromOptimisticNodeId,
createCanvasOpId,
DEFAULT_EDGE_OPTIONS,
EDGE_INTERSECTION_HIGHLIGHT_STYLE,
getConnectEndClientPoint,
getMiniMapNodeColor,
getMiniMapNodeStrokeColor,
getNodeCenterClientPosition,
getIntersectedEdgeId,
getPendingRemovedEdgeIdsFromLocalOps,
getPendingMovePinsFromLocalOps,
hasHandleKey,
isEditableKeyboardTarget,
isOptimisticEdgeId,
isOptimisticNodeId,
normalizeHandle,
OPTIMISTIC_EDGE_PREFIX,
OPTIMISTIC_NODE_PREFIX,
type PendingEdgeSplit,
withResolvedCompareData,
} from "./canvas-helpers";
import {
reconcileCanvasFlowEdges,
reconcileCanvasFlowNodes,
} from "./canvas-flow-reconciliation-helpers";
import { adjustNodeDimensionChanges } from "./canvas-node-change-helpers";
import { useGenerationFailureWarnings } from "./canvas-generation-failures";
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";
import { useCanvasData } from "./use-canvas-data";
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
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 summarizeUpdateDataPayload(payload: unknown): Record<string, unknown> {
if (typeof payload !== "object" || payload === null) {
return { payloadShape: "invalid" };
}
const p = payload as { nodeId?: unknown; data?: unknown };
const data =
typeof p.data === "object" && p.data !== null
? (p.data as Record<string, unknown>)
: null;
return {
nodeId: typeof p.nodeId === "string" ? p.nodeId : null,
hasData: Boolean(data),
hasStorageId: typeof data?.storageId === "string" && data.storageId.length > 0,
hasLastUploadStorageId:
typeof data?.lastUploadStorageId === "string" &&
data.lastUploadStorageId.length > 0,
hasUrl: typeof data?.url === "string" && data.url.length > 0,
hasLastUploadUrl:
typeof data?.lastUploadUrl === "string" && data.lastUploadUrl.length > 0,
lastUploadedAt:
typeof data?.lastUploadedAt === "number" && Number.isFinite(data.lastUploadedAt)
? data.lastUploadedAt
: null,
};
}
function summarizeResizePayload(payload: unknown): Record<string, unknown> {
if (typeof payload !== "object" || payload === null) {
return { payloadShape: "invalid" };
}
const p = payload as { nodeId?: unknown; width?: unknown; height?: unknown };
return {
nodeId: typeof p.nodeId === "string" ? p.nodeId : null,
width: typeof p.width === "number" && Number.isFinite(p.width) ? p.width : null,
height: typeof p.height === "number" && Number.isFinite(p.height) ? p.height : null,
};
}
function validateCanvasConnection(
connection: Connection,
nodes: RFNode[],
edges: RFEdge[],
edgeToReplaceId?: string,
): CanvasConnectionValidationReason | null {
if (!connection.source || !connection.target) return "incomplete";
if (connection.source === connection.target) return "self-loop";
const sourceNode = nodes.find((node) => node.id === connection.source);
const targetNode = nodes.find((node) => node.id === connection.target);
if (!sourceNode || !targetNode) return "unknown-node";
return validateCanvasConnectionPolicy({
sourceType: sourceNode.type ?? "",
targetType: targetNode.type ?? "",
targetIncomingCount: edges.filter(
(edge) => edge.target === connection.target && edge.id !== edgeToReplaceId,
).length,
});
}
function validateCanvasConnectionByType(args: {
sourceType: string;
targetType: string;
targetNodeId: string;
edges: RFEdge[];
}): CanvasConnectionValidationReason | null {
const targetIncomingCount = args.edges.filter(
(edge) => edge.target === args.targetNodeId,
).length;
return validateCanvasConnectionPolicy({
sourceType: args.sourceType,
targetType: args.targetType,
targetIncomingCount,
});
}
function CanvasInner({ canvasId }: CanvasInnerProps) {
const t = useTranslations('toasts');
const showConnectionRejectedToast = useCallback(
(reason: CanvasConnectionValidationReason) => {
showCanvasConnectionRejectedToast(t, reason);
},
[t],
);
const { screenToFlowPosition } = useReactFlow();
const { resolvedTheme } = useTheme();
const { canvas, convexEdges, convexNodes, storageUrlsById } = useCanvasData({
canvasId,
});
// ─── Future hook seam: sync engine ────────────────────────────
// 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 connectionState = useConvexConnectionState();
const pendingMoveAfterCreateRef = useRef(
new Map<string, { positionX: number; positionY: number }>(),
);
const pendingResizeAfterCreateRef = useRef(
new Map<string, { width: number; height: number }>(),
);
const pendingDataAfterCreateRef = useRef(new Map<string, unknown>());
const resolvedRealIdByClientRequestRef = useRef(new Map<string, Id<"nodes">>());
const pendingCreatePromiseByClientRequestRef = useRef(
new Map<string, Promise<Id<"nodes">>>(),
);
const pendingEdgeSplitByClientRequestRef = useRef(
new Map<string, PendingEdgeSplit>(),
);
const pendingDeleteAfterCreateClientRequestIdsRef = useRef(new Set<string>());
/** Connection-Drop → neue Node: erlaubt Carry-over der Kante in der Rollback-Lücke (ohne Phantom nach Fehler). */
const pendingConnectionCreatesRef = useRef(new Set<string>());
/** Nach create+drag: Convex liefert oft noch Erstellkoordinaten, bis `moveNode` committed — bis dahin Position pinnen. */
const pendingLocalPositionUntilConvexMatchesRef = useRef(
new Map<string, { x: number; y: number }>(),
);
/** Vorheriger Stand von api.nodes.list-IDs — um genau die neu eingetretene Node-ID vor Mutation-.then zu erkennen. */
const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set<string>());
const syncPendingMoveForClientRequestRef = useRef<
(clientRequestId: string | undefined, realId?: Id<"nodes">) => Promise<void>
>(async () => {});
const enqueueSyncMutationRef = useRef<
<TType extends keyof CanvasSyncOpPayloadByType>(
type: TType,
payload: CanvasSyncOpPayloadByType[TType],
) => Promise<void>
>(async () => {});
const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState<
string | null
>(null);
const [edgeSyncNonce, setEdgeSyncNonce] = useState(0);
/** Convex-Merge: Position nicht mit veraltetem Snapshot überschreiben (RF-`dragging` kommt oft verzögert). */
const preferLocalPositionNodeIdsRef = useRef(new Set<string>());
const createNode = useMutation(api.nodes.create).withOptimisticUpdate(
(localStore, args) => {
const current = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
if (current === undefined) return;
const tempId = (
args.clientRequestId
? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"nodes">;
const synthetic: Doc<"nodes"> = {
_id: tempId,
_creationTime: Date.now(),
canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"],
positionX: args.positionX,
positionY: args.positionY,
width: args.width,
height: args.height,
status: "idle",
retryCount: 0,
data: args.data,
parentId: args.parentId,
zIndex: args.zIndex,
};
localStore.setQuery(
api.nodes.list,
{ canvasId: args.canvasId },
[...current, synthetic],
);
},
);
const createNodeWithEdgeFromSource = useMutation(
api.nodes.createWithEdgeFromSource,
).withOptimisticUpdate((localStore, args) => {
const nodeList = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
const edgeList = localStore.getQuery(api.edges.list, {
canvasId: args.canvasId,
});
if (nodeList === undefined || edgeList === undefined) return;
const tempNodeId = (
args.clientRequestId
? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"nodes">;
const tempEdgeId = (
args.clientRequestId
? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"edges">;
const syntheticNode: Doc<"nodes"> = {
_id: tempNodeId,
_creationTime: Date.now(),
canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"],
positionX: args.positionX,
positionY: args.positionY,
width: args.width,
height: args.height,
status: "idle",
retryCount: 0,
data: args.data,
parentId: args.parentId,
zIndex: args.zIndex,
};
const sourceNode = nodeList.find((node) => node._id === args.sourceNodeId);
if (!sourceNode) return;
const syntheticEdge: Doc<"edges"> = {
_id: tempEdgeId,
_creationTime: Date.now(),
canvasId: args.canvasId,
sourceNodeId: sourceNode._id,
targetNodeId: tempNodeId,
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
};
localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [
...nodeList,
syntheticNode,
]);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [
...edgeList,
syntheticEdge,
]);
});
const createNodeWithEdgeToTarget = useMutation(
api.nodes.createWithEdgeToTarget,
).withOptimisticUpdate((localStore, args) => {
const nodeList = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
const edgeList = localStore.getQuery(api.edges.list, {
canvasId: args.canvasId,
});
if (nodeList === undefined || edgeList === undefined) return;
const tempNodeId = (
args.clientRequestId
? `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_NODE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"nodes">;
const tempEdgeId = (
args.clientRequestId
? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"edges">;
const syntheticNode: Doc<"nodes"> = {
_id: tempNodeId,
_creationTime: Date.now(),
canvasId: args.canvasId,
type: args.type as Doc<"nodes">["type"],
positionX: args.positionX,
positionY: args.positionY,
width: args.width,
height: args.height,
status: "idle",
retryCount: 0,
data: args.data,
parentId: args.parentId,
zIndex: args.zIndex,
};
const targetNode = nodeList.find((node) => node._id === args.targetNodeId);
if (!targetNode) return;
const syntheticEdge: Doc<"edges"> = {
_id: tempEdgeId,
_creationTime: Date.now(),
canvasId: args.canvasId,
sourceNodeId: tempNodeId,
targetNodeId: targetNode._id,
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
};
localStore.setQuery(api.nodes.list, { canvasId: args.canvasId }, [
...nodeList,
syntheticNode,
]);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, [
...edgeList,
syntheticEdge,
]);
});
const createNodeWithEdgeSplitMut = useMutation(api.nodes.createWithEdgeSplit);
const createEdge = useMutation(api.edges.create).withOptimisticUpdate(
(localStore, args) => {
const edgeList = localStore.getQuery(api.edges.list, {
canvasId: args.canvasId,
});
const nodeList = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
if (edgeList === undefined || nodeList === undefined) return;
const sourceNode = nodeList.find((node) => node._id === args.sourceNodeId);
const targetNode = nodeList.find((node) => node._id === args.targetNodeId);
if (!sourceNode || !targetNode) return;
const tempId = (
args.clientRequestId
? `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`
: `${OPTIMISTIC_EDGE_PREFIX}${Date.now()}_${Math.random().toString(36).slice(2, 11)}`
) as Id<"edges">;
const synthetic: Doc<"edges"> = {
_id: tempId,
_creationTime: Date.now(),
canvasId: args.canvasId,
sourceNodeId: sourceNode._id,
targetNodeId: targetNode._id,
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
};
localStore.setQuery(
api.edges.list,
{ canvasId: args.canvasId },
[...edgeList, synthetic],
);
},
);
const createNodeRaw = useMutation(api.nodes.create);
const createNodeWithEdgeFromSourceRaw = useMutation(
api.nodes.createWithEdgeFromSource,
);
const createNodeWithEdgeToTargetRaw = useMutation(
api.nodes.createWithEdgeToTarget,
);
const createNodeWithEdgeSplitRaw = useMutation(api.nodes.createWithEdgeSplit);
const createEdgeRaw = useMutation(api.edges.create);
const batchRemoveNodesRaw = useMutation(api.nodes.batchRemove);
const removeEdgeRaw = useMutation(api.edges.remove);
const splitEdgeAtExistingNodeRaw = useMutation(
api.nodes.splitEdgeAtExistingNode,
);
const [nodes, setNodes] = useState<RFNode[]>([]);
const [edges, setEdges] = useState<RFEdge[]>([]);
const hasPresetAwareNodes = useMemo(
() =>
nodes.some((node) => isAdjustmentPresetNodeType(node.type ?? "")) ||
(convexNodes ?? []).some((node) => isAdjustmentPresetNodeType(node.type)),
[convexNodes, nodes],
);
const edgesRef = useRef(edges);
edgesRef.current = edges;
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;
const trackPendingNodeCreate = useCallback(
(
clientRequestId: string,
createPromise: Promise<Id<"nodes">>,
): Promise<Id<"nodes">> => {
const trackedPromise = createPromise
.then((realId) => {
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
return realId;
})
.finally(() => {
pendingCreatePromiseByClientRequestRef.current.delete(clientRequestId);
});
pendingCreatePromiseByClientRequestRef.current.set(
clientRequestId,
trackedPromise,
);
return trackedPromise;
},
[],
);
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 aktuell nur online verfügbar.`,
);
}, []);
const addOptimisticNodeLocally = useCallback((
args: Parameters<typeof createNode>[0] & { clientRequestId: string },
): Id<"nodes"> => {
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`;
setNodes((current) => {
if (current.some((node) => node.id === optimisticNodeId)) {
return current;
}
return [
...current,
{
id: optimisticNodeId,
type: args.type,
position: { x: args.positionX, y: args.positionY },
data: args.data,
style: { width: args.width, height: args.height },
parentId: args.parentId as string | undefined,
zIndex: args.zIndex,
selected: false,
},
];
});
return optimisticNodeId as Id<"nodes">;
}, []);
const addOptimisticEdgeLocally = useCallback((args: {
clientRequestId: string;
sourceNodeId: string;
targetNodeId: string;
sourceHandle?: string;
targetHandle?: string;
}): Id<"edges"> => {
const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`;
setEdges((current) => {
if (current.some((edge) => edge.id === optimisticEdgeId)) {
return current;
}
return [
...current,
{
id: optimisticEdgeId,
source: args.sourceNodeId as string,
target: args.targetNodeId as string,
sourceHandle: args.sourceHandle,
targetHandle: args.targetHandle,
},
];
});
return optimisticEdgeId as Id<"edges">;
}, []);
const applyEdgeSplitLocally = useCallback((args: {
clientRequestId: string;
splitEdgeId: Id<"edges">;
middleNodeId: Id<"nodes">;
splitSourceHandle?: string;
splitTargetHandle?: string;
newNodeSourceHandle?: string;
newNodeTargetHandle?: string;
positionX?: number;
positionY?: number;
}): boolean => {
const splitEdgeId = args.splitEdgeId as string;
const splitEdge = edgesRef.current.find(
(edge) =>
edge.id === splitEdgeId &&
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id),
);
if (!splitEdge) {
return false;
}
const optimisticSplitEdgeBase = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`;
const optimisticSplitEdgeAId = `${optimisticSplitEdgeBase}_split_a`;
const optimisticSplitEdgeBId = `${optimisticSplitEdgeBase}_split_b`;
setEdges((current) => {
const existingSplitEdge = current.find((edge) => edge.id === splitEdgeId);
if (!existingSplitEdge) {
return current;
}
const next = current.filter(
(edge) =>
edge.id !== splitEdgeId &&
edge.id !== optimisticSplitEdgeAId &&
edge.id !== optimisticSplitEdgeBId,
);
next.push(
{
id: optimisticSplitEdgeAId,
source: existingSplitEdge.source,
target: args.middleNodeId as string,
sourceHandle: args.splitSourceHandle,
targetHandle: args.newNodeTargetHandle,
},
{
id: optimisticSplitEdgeBId,
source: args.middleNodeId as string,
target: existingSplitEdge.target,
sourceHandle: args.newNodeSourceHandle,
targetHandle: args.splitTargetHandle,
},
);
return next;
});
if (args.positionX !== undefined && args.positionY !== undefined) {
const x = args.positionX;
const y = args.positionY;
const middleNodeId = args.middleNodeId as string;
setNodes((current) =>
current.map((node) =>
node.id === middleNodeId
? {
...node,
position: { x, y },
}
: node,
),
);
}
return true;
}, []);
const removeOptimisticCreateLocally = useCallback((args: {
clientRequestId: string;
removeNode?: boolean;
removeEdge?: boolean;
}): void => {
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${args.clientRequestId}`;
const optimisticEdgeId = `${OPTIMISTIC_EDGE_PREFIX}${args.clientRequestId}`;
if (args.removeNode) {
setNodes((current) =>
current.filter((node) => node.id !== optimisticNodeId),
);
setEdges((current) =>
current.filter(
(edge) =>
edge.source !== optimisticNodeId && edge.target !== optimisticNodeId,
),
);
}
if (args.removeEdge) {
const optimisticEdgePrefix = `${optimisticEdgeId}_`;
setEdges((current) =>
current.filter(
(edge) =>
edge.id !== optimisticEdgeId &&
!edge.id.startsWith(optimisticEdgePrefix),
),
);
}
pendingMoveAfterCreateRef.current.delete(args.clientRequestId);
pendingResizeAfterCreateRef.current.delete(args.clientRequestId);
pendingDataAfterCreateRef.current.delete(args.clientRequestId);
pendingCreatePromiseByClientRequestRef.current.delete(args.clientRequestId);
pendingEdgeSplitByClientRequestRef.current.delete(args.clientRequestId);
pendingConnectionCreatesRef.current.delete(args.clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(args.clientRequestId);
}, []);
const remapOptimisticNodeLocally = useCallback(async (
clientRequestId: string,
realId: Id<"nodes">,
): Promise<void> => {
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
const realNodeId = realId as string;
if (
pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId)
) {
pendingDeleteAfterCreateClientRequestIdsRef.current.delete(clientRequestId);
removeOptimisticCreateLocally({
clientRequestId,
removeNode: true,
removeEdge: true,
});
deletingNodeIds.current.add(realNodeId);
await enqueueSyncMutationRef.current("batchRemoveNodes", {
nodeIds: [realId],
});
return;
}
setNodes((current) =>
current.map((node) => {
const nextParentId =
node.parentId === optimisticNodeId ? realNodeId : node.parentId;
if (node.id !== optimisticNodeId && nextParentId === node.parentId) {
return node;
}
return {
...node,
id: node.id === optimisticNodeId ? realNodeId : node.id,
parentId: nextParentId,
};
}),
);
setEdges((current) =>
current.map((edge) => {
const nextSource =
edge.source === optimisticNodeId ? realNodeId : edge.source;
const nextTarget =
edge.target === optimisticNodeId ? realNodeId : edge.target;
if (nextSource === edge.source && nextTarget === edge.target) {
return edge;
}
return {
...edge,
source: nextSource,
target: nextTarget,
};
}),
);
setAssetBrowserTargetNodeId((current) =>
current === optimisticNodeId ? realNodeId : current,
);
const pinnedPos =
pendingLocalPositionUntilConvexMatchesRef.current.get(optimisticNodeId);
if (pinnedPos) {
pendingLocalPositionUntilConvexMatchesRef.current.delete(optimisticNodeId);
pendingLocalPositionUntilConvexMatchesRef.current.set(realNodeId, pinnedPos);
}
if (preferLocalPositionNodeIdsRef.current.has(optimisticNodeId)) {
preferLocalPositionNodeIdsRef.current.delete(optimisticNodeId);
preferLocalPositionNodeIdsRef.current.add(realNodeId);
}
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
await remapCanvasSyncNodeId(canvasId as string, optimisticNodeId, realNodeId);
remapCanvasOpNodeId(canvasId as string, optimisticNodeId, realNodeId);
}, [canvasId, removeOptimisticCreateLocally]);
const runCreateNodeOnlineOnly = useCallback(
async (args: Parameters<typeof createNode>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
if (isSyncOnline) {
return await trackPendingNodeCreate(clientRequestId, createNode(payload));
}
const optimisticNodeId = addOptimisticNodeLocally(payload);
await enqueueSyncMutationRef.current("createNode", payload);
return optimisticNodeId;
},
[addOptimisticNodeLocally, createNode, isSyncOnline, trackPendingNodeCreate],
);
const runCreateNodeWithEdgeFromSourceOnlineOnly = useCallback(
async (args: Parameters<typeof createNodeWithEdgeFromSource>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
const sourceNodeId = payload.sourceNodeId as string;
pendingConnectionCreatesRef.current.add(clientRequestId);
if (isSyncOnline && !isOptimisticNodeId(sourceNodeId)) {
return await trackPendingNodeCreate(
clientRequestId,
createNodeWithEdgeFromSource(payload),
);
}
const optimisticNodeId = addOptimisticNodeLocally(payload);
addOptimisticEdgeLocally({
clientRequestId,
sourceNodeId: payload.sourceNodeId,
targetNodeId: optimisticNodeId,
sourceHandle: payload.sourceHandle,
targetHandle: payload.targetHandle,
});
if (isSyncOnline) {
try {
const realId = await trackPendingNodeCreate(clientRequestId, createNodeWithEdgeFromSourceRaw({
...payload,
}));
await remapOptimisticNodeLocally(clientRequestId, realId);
return realId;
} catch (error) {
removeOptimisticCreateLocally({
clientRequestId,
removeNode: true,
removeEdge: true,
});
throw error;
}
}
await enqueueSyncMutationRef.current(
"createNodeWithEdgeFromSource",
payload,
);
return optimisticNodeId;
},
[
addOptimisticEdgeLocally,
addOptimisticNodeLocally,
createNodeWithEdgeFromSource,
createNodeWithEdgeFromSourceRaw,
isSyncOnline,
remapOptimisticNodeLocally,
removeOptimisticCreateLocally,
trackPendingNodeCreate,
],
);
const runCreateNodeWithEdgeToTargetOnlineOnly = useCallback(
async (args: Parameters<typeof createNodeWithEdgeToTarget>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
const targetNodeId = payload.targetNodeId as string;
pendingConnectionCreatesRef.current.add(clientRequestId);
if (isSyncOnline && !isOptimisticNodeId(targetNodeId)) {
return await trackPendingNodeCreate(
clientRequestId,
createNodeWithEdgeToTarget(payload),
);
}
const optimisticNodeId = addOptimisticNodeLocally(payload);
addOptimisticEdgeLocally({
clientRequestId,
sourceNodeId: optimisticNodeId,
targetNodeId: payload.targetNodeId,
sourceHandle: payload.sourceHandle,
targetHandle: payload.targetHandle,
});
if (isSyncOnline) {
try {
const realId = await trackPendingNodeCreate(clientRequestId, createNodeWithEdgeToTargetRaw({
...payload,
}));
await remapOptimisticNodeLocally(clientRequestId, realId);
return realId;
} catch (error) {
removeOptimisticCreateLocally({
clientRequestId,
removeNode: true,
removeEdge: true,
});
throw error;
}
}
await enqueueSyncMutationRef.current("createNodeWithEdgeToTarget", payload);
return optimisticNodeId;
},
[
addOptimisticEdgeLocally,
addOptimisticNodeLocally,
createNodeWithEdgeToTarget,
createNodeWithEdgeToTargetRaw,
isSyncOnline,
remapOptimisticNodeLocally,
removeOptimisticCreateLocally,
trackPendingNodeCreate,
],
);
const runCreateNodeWithEdgeSplitOnlineOnly = useCallback(
async (args: Parameters<typeof createNodeWithEdgeSplitMut>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
if (isSyncOnline) {
return await createNodeWithEdgeSplitMut(payload);
}
const optimisticNodeId = addOptimisticNodeLocally(payload);
const splitApplied = applyEdgeSplitLocally({
clientRequestId,
splitEdgeId: payload.splitEdgeId,
middleNodeId: optimisticNodeId,
splitSourceHandle: payload.splitSourceHandle,
splitTargetHandle: payload.splitTargetHandle,
newNodeSourceHandle: payload.newNodeSourceHandle,
newNodeTargetHandle: payload.newNodeTargetHandle,
positionX: payload.positionX,
positionY: payload.positionY,
});
if (splitApplied) {
await enqueueSyncMutationRef.current("createNodeWithEdgeSplit", payload);
} else {
await enqueueSyncMutationRef.current("createNode", {
canvasId: payload.canvasId,
type: payload.type,
positionX: payload.positionX,
positionY: payload.positionY,
width: payload.width,
height: payload.height,
data: payload.data,
parentId: payload.parentId,
zIndex: payload.zIndex,
clientRequestId,
});
}
return optimisticNodeId;
},
[addOptimisticNodeLocally, applyEdgeSplitLocally, createNodeWithEdgeSplitMut, isSyncOnline],
);
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.`,
);
}
let permanentFailures = 0;
let processedInThisPass = 0;
while (processedInThisPass < 500) {
const nowLoop = Date.now();
const queue = await listCanvasSyncOps(canvasId as string);
const op = queue.find(
(entry) => entry.expiresAt > nowLoop && entry.nextRetryAt <= nowLoop,
);
if (!op) break;
processedInThisPass += 1;
try {
if (op.type === "createNode") {
const realId = await createNodeRaw(
op.payload as Parameters<typeof createNodeRaw>[0],
);
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
await syncPendingMoveForClientRequestRef.current(
op.payload.clientRequestId,
realId,
);
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "createNodeWithEdgeFromSource") {
const realId = await createNodeWithEdgeFromSourceRaw(
op.payload as Parameters<typeof createNodeWithEdgeFromSourceRaw>[0],
);
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
await syncPendingMoveForClientRequestRef.current(
op.payload.clientRequestId,
realId,
);
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "createNodeWithEdgeToTarget") {
const realId = await createNodeWithEdgeToTargetRaw(
op.payload as Parameters<typeof createNodeWithEdgeToTargetRaw>[0],
);
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
await syncPendingMoveForClientRequestRef.current(
op.payload.clientRequestId,
realId,
);
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "createNodeWithEdgeSplit") {
const realId = await createNodeWithEdgeSplitRaw(
op.payload as Parameters<typeof createNodeWithEdgeSplitRaw>[0],
);
await remapOptimisticNodeLocally(op.payload.clientRequestId, realId);
await syncPendingMoveForClientRequestRef.current(
op.payload.clientRequestId,
realId,
);
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "createEdge") {
await createEdgeRaw(op.payload);
} else if (op.type === "removeEdge") {
await removeEdgeRaw(op.payload);
} else if (op.type === "batchRemoveNodes") {
await batchRemoveNodesRaw(op.payload);
} else if (op.type === "splitEdgeAtExistingNode") {
await splitEdgeAtExistingNodeRaw(op.payload);
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "moveNode") {
await moveNode(op.payload);
} else if (op.type === "resizeNode") {
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] resizeNode enqueue->flush", {
opId: op.id,
attemptCount: op.attemptCount,
...summarizeResizePayload(op.payload),
});
}
await resizeNode(op.payload);
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] resizeNode flush success", {
opId: op.id,
...summarizeResizePayload(op.payload),
});
}
} else if (op.type === "updateData") {
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] updateData enqueue->flush", {
opId: op.id,
attemptCount: op.attemptCount,
...summarizeUpdateDataPayload(op.payload),
});
}
await updateNodeData(op.payload);
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] updateData flush success", {
opId: op.id,
...summarizeUpdateDataPayload(op.payload),
});
}
}
await ackCanvasSyncOp(op.id);
resolveCanvasOp(canvasId as string, op.id);
} catch (error: unknown) {
const transient =
!isSyncOnline || isLikelyTransientSyncError(error);
if (op.type === "updateData" && process.env.NODE_ENV !== "production") {
console.warn("[Canvas sync debug] updateData flush failed", {
opId: op.id,
attemptCount: op.attemptCount,
transient,
error: getErrorMessage(error),
...summarizeUpdateDataPayload(op.payload),
});
}
if (op.type === "resizeNode" && process.env.NODE_ENV !== "production") {
const resizeNodeId =
typeof op.payload.nodeId === "string" ? op.payload.nodeId : null;
const resizeClientRequestId = resizeNodeId
? clientRequestIdFromOptimisticNodeId(resizeNodeId)
: null;
const resizeResolvedRealId = resizeClientRequestId
? resolvedRealIdByClientRequestRef.current.get(resizeClientRequestId)
: null;
console.warn("[Canvas sync debug] resizeNode flush failed", {
opId: op.id,
attemptCount: op.attemptCount,
transient,
error: getErrorMessage(error),
clientRequestId: resizeClientRequestId,
resolvedRealId: resizeResolvedRealId ?? null,
...summarizeResizePayload(op.payload),
});
}
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;
if (op.type === "createNode") {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeNode: true,
});
} else if (
op.type === "createNodeWithEdgeFromSource" ||
op.type === "createNodeWithEdgeToTarget"
) {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeNode: true,
removeEdge: true,
});
} else if (op.type === "createNodeWithEdgeSplit") {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeNode: true,
removeEdge: true,
});
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "createEdge") {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeEdge: true,
});
} else if (op.type === "splitEdgeAtExistingNode") {
removeOptimisticCreateLocally({
clientRequestId: op.payload.clientRequestId,
removeEdge: true,
});
setEdgeSyncNonce((value) => value + 1);
} else if (op.type === "batchRemoveNodes") {
for (const nodeId of op.payload.nodeIds) {
deletingNodeIds.current.delete(nodeId as string);
}
}
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();
}
}, [
batchRemoveNodesRaw,
canvasId,
createEdgeRaw,
createNodeRaw,
createNodeWithEdgeFromSourceRaw,
createNodeWithEdgeSplitRaw,
createNodeWithEdgeToTargetRaw,
isSyncOnline,
moveNode,
refreshPendingSyncCount,
remapOptimisticNodeLocally,
removeEdgeRaw,
removeOptimisticCreateLocally,
resizeNode,
splitEdgeAtExistingNodeRaw,
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],
);
enqueueSyncMutationRef.current = enqueueSyncMutation;
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 }) => {
await enqueueSyncMutation("moveNode", args);
},
[enqueueSyncMutation],
);
const runBatchMoveNodesMutation = useCallback(
async (args: {
moves: { nodeId: Id<"nodes">; positionX: number; positionY: number }[];
}) => {
for (const move of args.moves) {
await enqueueSyncMutation("moveNode", move);
}
},
[enqueueSyncMutation],
);
const flushPendingResizeForClientRequest = useCallback(
async (clientRequestId: string, realId: Id<"nodes">): Promise<void> => {
const pendingResize = pendingResizeAfterCreateRef.current.get(clientRequestId);
if (!pendingResize) return;
pendingResizeAfterCreateRef.current.delete(clientRequestId);
await enqueueSyncMutation("resizeNode", {
nodeId: realId,
width: pendingResize.width,
height: pendingResize.height,
});
},
[enqueueSyncMutation],
);
const flushPendingDataForClientRequest = useCallback(
async (clientRequestId: string, realId: Id<"nodes">): Promise<void> => {
if (!pendingDataAfterCreateRef.current.has(clientRequestId)) return;
const pendingData = pendingDataAfterCreateRef.current.get(clientRequestId);
pendingDataAfterCreateRef.current.delete(clientRequestId);
await enqueueSyncMutation("updateData", {
nodeId: realId,
data: pendingData,
});
},
[enqueueSyncMutation],
);
const runResizeNodeMutation = useCallback(
async (args: { nodeId: Id<"nodes">; width: number; height: number }) => {
const rawNodeId = args.nodeId as string;
if (!isOptimisticNodeId(rawNodeId)) {
await enqueueSyncMutation("resizeNode", args);
return;
}
if (!isSyncOnline) {
await enqueueSyncMutation("resizeNode", args);
return;
}
const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId);
const resolvedRealId = clientRequestId
? resolvedRealIdByClientRequestRef.current.get(clientRequestId)
: undefined;
if (resolvedRealId) {
await enqueueSyncMutation("resizeNode", {
nodeId: resolvedRealId,
width: args.width,
height: args.height,
});
return;
}
if (clientRequestId) {
pendingResizeAfterCreateRef.current.set(clientRequestId, {
width: args.width,
height: args.height,
});
}
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] deferred resize for optimistic node", {
nodeId: rawNodeId,
clientRequestId,
resolvedRealId: resolvedRealId ?? null,
width: args.width,
height: args.height,
});
}
},
[enqueueSyncMutation, isSyncOnline],
);
const runUpdateNodeDataMutation = useCallback(
async (args: { nodeId: Id<"nodes">; data: unknown }) => {
const rawNodeId = args.nodeId as string;
if (!isOptimisticNodeId(rawNodeId)) {
await enqueueSyncMutation("updateData", args);
return;
}
if (!isSyncOnline) {
await enqueueSyncMutation("updateData", args);
return;
}
const clientRequestId = clientRequestIdFromOptimisticNodeId(rawNodeId);
const resolvedRealId = clientRequestId
? resolvedRealIdByClientRequestRef.current.get(clientRequestId)
: undefined;
if (resolvedRealId) {
await enqueueSyncMutation("updateData", {
nodeId: resolvedRealId,
data: args.data,
});
return;
}
if (clientRequestId) {
pendingDataAfterCreateRef.current.set(clientRequestId, args.data);
}
if (process.env.NODE_ENV !== "production") {
console.info("[Canvas sync debug] deferred updateData for optimistic node", {
nodeId: rawNodeId,
clientRequestId,
resolvedRealId: resolvedRealId ?? null,
hasData: args.data !== undefined,
});
}
},
[enqueueSyncMutation, isSyncOnline],
);
const runBatchRemoveNodesMutation = useCallback(
async (args: { nodeIds: Id<"nodes">[] }) => {
const ids = args.nodeIds.map((id) => id as string);
const optimisticNodeIds = ids.filter((id) => isOptimisticNodeId(id));
const persistedNodeIds = ids.filter((id) => !isOptimisticNodeId(id));
const createClientRequestIds = optimisticNodeIds
.map((id) => clientRequestIdFromOptimisticNodeId(id))
.filter((id): id is string => id !== null);
if (createClientRequestIds.length > 0) {
if (isSyncOnline) {
for (const clientRequestId of createClientRequestIds) {
pendingDeleteAfterCreateClientRequestIdsRef.current.add(
clientRequestId,
);
}
}
const droppedSync = await dropCanvasSyncOpsByClientRequestIds(
canvasId as string,
createClientRequestIds,
);
const droppedLocal = dropCanvasOpsByClientRequestIds(
canvasId as string,
createClientRequestIds,
);
for (const clientRequestId of createClientRequestIds) {
removeOptimisticCreateLocally({
clientRequestId,
removeNode: true,
removeEdge: true,
});
}
resolveCanvasOps(canvasId as string, droppedSync);
resolveCanvasOps(canvasId as string, droppedLocal);
}
if (persistedNodeIds.length === 0) {
await refreshPendingSyncCount();
return;
}
const droppedSyncByNode = await dropCanvasSyncOpsByNodeIds(
canvasId as string,
persistedNodeIds,
);
const droppedLocalByNode = dropCanvasOpsByNodeIds(
canvasId as string,
persistedNodeIds,
);
resolveCanvasOps(canvasId as string, droppedSyncByNode);
resolveCanvasOps(canvasId as string, droppedLocalByNode);
await enqueueSyncMutation("batchRemoveNodes", {
nodeIds: persistedNodeIds as Id<"nodes">[],
});
},
[
canvasId,
enqueueSyncMutation,
isSyncOnline,
refreshPendingSyncCount,
removeOptimisticCreateLocally,
],
);
const runCreateEdgeMutation = useCallback(
async (args: Parameters<typeof createEdge>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
if (isSyncOnline) {
await createEdge(payload);
return;
}
addOptimisticEdgeLocally({
clientRequestId,
sourceNodeId: payload.sourceNodeId,
targetNodeId: payload.targetNodeId,
sourceHandle: payload.sourceHandle,
targetHandle: payload.targetHandle,
});
await enqueueSyncMutation("createEdge", payload);
},
[addOptimisticEdgeLocally, createEdge, enqueueSyncMutation, isSyncOnline],
);
const runRemoveEdgeMutation = useCallback(
async (args: { edgeId: Id<"edges"> }) => {
const edgeId = args.edgeId as string;
setEdges((current) => current.filter((edge) => edge.id !== edgeId));
if (isOptimisticEdgeId(edgeId)) {
const clientRequestId = clientRequestIdFromOptimisticEdgeId(edgeId);
if (clientRequestId) {
const droppedSync = await dropCanvasSyncOpsByClientRequestIds(
canvasId as string,
[clientRequestId],
);
const droppedLocal = dropCanvasOpsByClientRequestIds(
canvasId as string,
[clientRequestId],
);
resolveCanvasOps(canvasId as string, droppedSync);
resolveCanvasOps(canvasId as string, droppedLocal);
}
await refreshPendingSyncCount();
return;
}
const droppedSync = await dropCanvasSyncOpsByEdgeIds(canvasId as string, [edgeId]);
const droppedLocal = dropCanvasOpsByEdgeIds(canvasId as string, [edgeId]);
resolveCanvasOps(canvasId as string, droppedSync);
resolveCanvasOps(canvasId as string, droppedLocal);
await enqueueSyncMutation("removeEdge", {
edgeId: edgeId as Id<"edges">,
});
},
[canvasId, enqueueSyncMutation, refreshPendingSyncCount],
);
const splitEdgeAtExistingNodeMut = useMutation(
api.nodes.splitEdgeAtExistingNode,
).withOptimisticUpdate((localStore, args) => {
const edgeList = localStore.getQuery(api.edges.list, {
canvasId: args.canvasId,
});
const nodeList = localStore.getQuery(api.nodes.list, {
canvasId: args.canvasId,
});
if (edgeList === undefined || nodeList === undefined) return;
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: Doc<"edges">) => e._id !== args.splitEdgeId,
);
nextEdges.push(
{
_id: t1,
_creationTime: now,
canvasId: args.canvasId,
sourceNodeId: removed.sourceNodeId,
targetNodeId: args.middleNodeId,
sourceHandle: args.splitSourceHandle,
targetHandle: args.newNodeTargetHandle,
},
{
_id: t2,
_creationTime: now,
canvasId: args.canvasId,
sourceNodeId: args.middleNodeId,
targetNodeId: removed.targetNodeId,
sourceHandle: args.newNodeSourceHandle,
targetHandle: args.splitTargetHandle,
},
);
localStore.setQuery(api.edges.list, { canvasId: args.canvasId }, nextEdges);
if (args.positionX !== undefined && args.positionY !== undefined) {
const px = args.positionX;
const py = args.positionY;
localStore.setQuery(
api.nodes.list,
{ canvasId: args.canvasId },
nodeList.map((n: Doc<"nodes">) =>
n._id === args.middleNodeId
? {
...n,
positionX: px,
positionY: py,
}
: n,
),
);
}
});
const runSplitEdgeAtExistingNodeMutation = useCallback(
async (args: Parameters<typeof splitEdgeAtExistingNodeMut>[0]) => {
const clientRequestId = args.clientRequestId ?? crypto.randomUUID();
const payload = { ...args, clientRequestId };
if (isSyncOnline) {
await splitEdgeAtExistingNodeMut(payload);
return;
}
const splitApplied = applyEdgeSplitLocally({
clientRequestId,
splitEdgeId: payload.splitEdgeId,
middleNodeId: payload.middleNodeId,
splitSourceHandle: payload.splitSourceHandle,
splitTargetHandle: payload.splitTargetHandle,
newNodeSourceHandle: payload.newNodeSourceHandle,
newNodeTargetHandle: payload.newNodeTargetHandle,
positionX: payload.positionX,
positionY: payload.positionY,
});
if (!splitApplied) return;
await enqueueSyncMutation("splitEdgeAtExistingNode", payload);
},
[
applyEdgeSplitLocally,
enqueueSyncMutation,
isSyncOnline,
splitEdgeAtExistingNodeMut,
],
);
/** Freepik-Panel: State canvas-weit, damit es den optimistic_… → Real-ID-Wechsel überlebt. */
const assetBrowserTargetApi: AssetBrowserTargetApi = useMemo(
() => ({
targetNodeId: assetBrowserTargetNodeId,
openForNode: (nodeId: string) => setAssetBrowserTargetNodeId(nodeId),
close: () => setAssetBrowserTargetNodeId(null),
}),
[assetBrowserTargetNodeId],
);
/** Pairing: create kann vor oder nach Drag-Ende fertig sein. Kanten-Split + Position in einem Convex-Roundtrip wenn split ansteht. */
const syncPendingMoveForClientRequest = useCallback(
async (
clientRequestId: string | undefined,
realId?: Id<"nodes">,
): Promise<void> => {
if (!clientRequestId) return;
if (realId !== undefined) {
if (isOptimisticNodeId(realId as string)) {
return;
}
if (
pendingDeleteAfterCreateClientRequestIdsRef.current.has(clientRequestId)
) {
pendingDeleteAfterCreateClientRequestIdsRef.current.delete(
clientRequestId,
);
pendingMoveAfterCreateRef.current.delete(clientRequestId);
pendingResizeAfterCreateRef.current.delete(clientRequestId);
pendingDataAfterCreateRef.current.delete(clientRequestId);
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
pendingConnectionCreatesRef.current.delete(clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
const realNodeId = realId as string;
deletingNodeIds.current.add(realNodeId);
setNodes((current) =>
current.filter((node) => node.id !== realNodeId),
);
setEdges((current) =>
current.filter(
(edge) =>
edge.source !== realNodeId && edge.target !== realNodeId,
),
);
await runBatchRemoveNodesMutation({ nodeIds: [realId] });
return;
}
const optimisticNodeId = `${OPTIMISTIC_NODE_PREFIX}${clientRequestId}`;
setAssetBrowserTargetNodeId((current) =>
current === optimisticNodeId ? (realId as string) : current,
);
const pendingMove = pendingMoveAfterCreateRef.current.get(clientRequestId);
const splitPayload =
pendingEdgeSplitByClientRequestRef.current.get(clientRequestId);
if (splitPayload) {
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
if (pendingMove) {
pendingMoveAfterCreateRef.current.delete(clientRequestId);
}
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
try {
await runSplitEdgeAtExistingNodeMutation({
canvasId,
splitEdgeId: splitPayload.intersectedEdgeId,
middleNodeId: realId,
splitSourceHandle: splitPayload.intersectedSourceHandle,
splitTargetHandle: splitPayload.intersectedTargetHandle,
newNodeSourceHandle: splitPayload.middleSourceHandle,
newNodeTargetHandle: splitPayload.middleTargetHandle,
positionX: pendingMove?.positionX ?? splitPayload.positionX,
positionY: pendingMove?.positionY ?? splitPayload.positionY,
});
} catch (error: unknown) {
console.error("[Canvas pending edge split failed]", {
clientRequestId,
realId,
error: String(error),
});
}
await flushPendingResizeForClientRequest(clientRequestId, realId);
await flushPendingDataForClientRequest(clientRequestId, realId);
return;
}
if (pendingMove) {
pendingMoveAfterCreateRef.current.delete(clientRequestId);
// Ref bewusst NICHT löschen: Edge-Sync braucht clientRequestId→realId für
// Remap/Carry-over, solange convexNodes/convexEdges nach Mutation kurz auseinanderlaufen.
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
pendingLocalPositionUntilConvexMatchesRef.current.set(
realId as string,
{
x: pendingMove.positionX,
y: pendingMove.positionY,
},
);
await runMoveNodeMutation({
nodeId: realId,
positionX: pendingMove.positionX,
positionY: pendingMove.positionY,
});
await flushPendingResizeForClientRequest(clientRequestId, realId);
await flushPendingDataForClientRequest(clientRequestId, realId);
return;
}
resolvedRealIdByClientRequestRef.current.set(clientRequestId, realId);
await flushPendingResizeForClientRequest(clientRequestId, realId);
await flushPendingDataForClientRequest(clientRequestId, realId);
return;
}
const r = resolvedRealIdByClientRequestRef.current.get(clientRequestId);
const p = pendingMoveAfterCreateRef.current.get(clientRequestId);
if (!r || !p) return;
pendingMoveAfterCreateRef.current.delete(clientRequestId);
resolvedRealIdByClientRequestRef.current.delete(clientRequestId);
const splitPayload =
pendingEdgeSplitByClientRequestRef.current.get(clientRequestId);
if (splitPayload) {
pendingEdgeSplitByClientRequestRef.current.delete(clientRequestId);
try {
await runSplitEdgeAtExistingNodeMutation({
canvasId,
splitEdgeId: splitPayload.intersectedEdgeId,
middleNodeId: r,
splitSourceHandle: splitPayload.intersectedSourceHandle,
splitTargetHandle: splitPayload.intersectedTargetHandle,
newNodeSourceHandle: splitPayload.middleSourceHandle,
newNodeTargetHandle: splitPayload.middleTargetHandle,
positionX: splitPayload.positionX ?? p.positionX,
positionY: splitPayload.positionY ?? p.positionY,
});
} catch (error: unknown) {
console.error("[Canvas pending edge split failed]", {
clientRequestId,
realId: r,
error: String(error),
});
}
await flushPendingDataForClientRequest(clientRequestId, r);
} else {
pendingLocalPositionUntilConvexMatchesRef.current.set(r as string, {
x: p.positionX,
y: p.positionY,
});
await runMoveNodeMutation({
nodeId: r,
positionX: p.positionX,
positionY: p.positionY,
});
await flushPendingDataForClientRequest(clientRequestId, r);
}
},
[
canvasId,
runBatchRemoveNodesMutation,
flushPendingDataForClientRequest,
flushPendingResizeForClientRequest,
runMoveNodeMutation,
runSplitEdgeAtExistingNodeMutation,
],
);
syncPendingMoveForClientRequestRef.current = syncPendingMoveForClientRequest;
// ─── Future hook seam: render composition + shared local flow state ─────
const nodesRef = useRef<RFNode[]>(nodes);
nodesRef.current = nodes;
const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
connectionDropMenuRef.current = connectionDropMenu;
const [scissorsMode, setScissorsMode] = useState(false);
const [scissorStrokePreview, setScissorStrokePreview] = useState<
{ x: number; y: number }[] | null
>(null);
const [navTool, setNavTool] = useState<CanvasNavTool>("select");
useCanvasLocalSnapshotPersistence<RFNode, RFEdge>({
canvasId: canvasId as string,
nodes,
edges,
setNodes,
setEdges,
});
const handleNavToolChange = useCallback((tool: CanvasNavTool) => {
if (tool === "scissor") {
setScissorsMode(true);
setNavTool("scissor");
return;
}
setScissorsMode(false);
setNavTool(tool);
}, []);
// Auswahl (V) / Hand (H) — ergänzt die Leertaste (Standard: panActivationKeyCode Space beim Ziehen)
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (isEditableKeyboardTarget(e.target)) return;
const key = e.key.length === 1 ? e.key.toLowerCase() : "";
if (key === "v") {
e.preventDefault();
handleNavToolChange("select");
return;
}
if (key === "h") {
e.preventDefault();
handleNavToolChange("hand");
return;
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [handleNavToolChange]);
const { flowPanOnDrag, flowSelectionOnDrag } = useMemo(() => {
const panMiddleRight: number[] = [1, 2];
if (scissorsMode) {
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: false };
}
if (navTool === "hand") {
return { flowPanOnDrag: true, flowSelectionOnDrag: false };
}
if (navTool === "comment") {
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
}
return { flowPanOnDrag: panMiddleRight, flowSelectionOnDrag: true };
}, [scissorsMode, navTool]);
const scissorsModeRef = useRef(scissorsMode);
scissorsModeRef.current = scissorsMode;
// Drag-Lock: während des Drags kein Convex-Override
const isDragging = useRef(false);
// Resize-Lock: kein Convex→lokal während aktiver Größenänderung (veraltete Maße überschreiben sonst den Resize)
const isResizing = useRef(false);
// Delete-Lock: Nodes die gerade gelöscht werden, nicht aus Convex-Sync wiederherstellen
const deletingNodeIds = useRef<Set<string>>(new Set());
// Delete Edge on Drop
const edgeReconnectSuccessful = useRef(true);
const isReconnectDragActiveRef = useRef(false);
const overlappedEdgeRef = useRef<string | null>(null);
const highlightedEdgeRef = useRef<string | null>(null);
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
undefined,
);
useGenerationFailureWarnings(t, convexNodes);
const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({
scissorsMode,
scissorsModeRef,
edgesRef,
setScissorsMode,
setNavTool,
setScissorStrokePreview,
runRemoveEdgeMutation,
});
const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({
t,
canvasId,
nodes,
edges,
deletingNodeIds,
setAssetBrowserTargetNodeId,
runBatchRemoveNodesMutation,
runCreateEdgeMutation,
runRemoveEdgeMutation,
});
const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({
canvasId,
edgeReconnectSuccessful,
isReconnectDragActiveRef,
setEdges,
runCreateEdgeMutation,
runRemoveEdgeMutation,
validateConnection: (oldEdge, nextConnection) =>
validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id),
onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
},
});
// ─── Future hook seam: flow reconciliation ────────────────────
/**
* 1) Kanten: Carry/Inferenz setzt ggf. `resolvedRealIdByClientRequestRef` (auch bevor Mutation-.then läuft).
* 2) Nodes: gleicher Commit, vor Paint — echte Node-IDs passen zu Kanten-Endpunkten (verhindert „reißende“ Kanten).
* Während Drag (`isDraggingRef` oder `node.dragging`): nur optimistic→real-Handoff.
*/
useLayoutEffect(() => {
if (!convexEdges) return;
setEdges((prev) => {
const reconciliation = reconcileCanvasFlowEdges({
previousEdges: prev,
convexEdges,
convexNodes,
previousConvexNodeIdsSnapshot: convexNodeIdsSnapshotForEdgeCarryRef.current,
pendingRemovedEdgeIds: getPendingRemovedEdgeIdsFromLocalOps(canvasId as string),
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current,
localNodeIds: new Set(nodesRef.current.map((node) => node.id)),
isAnyNodeDragging:
isDragging.current ||
nodesRef.current.some((node) =>
Boolean((node as { dragging?: boolean }).dragging),
),
colorMode: resolvedTheme === "dark" ? "dark" : "light",
});
resolvedRealIdByClientRequestRef.current =
reconciliation.inferredRealIdByClientRequest;
convexNodeIdsSnapshotForEdgeCarryRef.current =
reconciliation.nextConvexNodeIdsSnapshot;
for (const clientRequestId of reconciliation.settledPendingConnectionCreateIds) {
pendingConnectionCreatesRef.current.delete(clientRequestId);
}
return reconciliation.edges;
});
}, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]);
useLayoutEffect(() => {
if (!convexNodes || isResizing.current) return;
setNodes((previousNodes) => {
/** RF setzt `node.dragging` + Position oft bevor `onNodeDragStart` `isDraggingRef` setzt — ohne diese Zeile zieht useLayoutEffect Convex-Stand darüber („Kleben“). */
const anyRfNodeDragging = previousNodes.some((n) =>
Boolean((n as { dragging?: boolean }).dragging),
);
if (isDragging.current || anyRfNodeDragging) {
// 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: Doc<"nodes">) =>
convexNodeDocWithMergedStorageUrl(
node,
storageUrlsById,
prevDataById,
),
);
const incomingNodes = withResolvedCompareData(
enriched.map(convexNodeToRF),
edges,
);
const reconciliation = reconcileCanvasFlowNodes({
previousNodes,
incomingNodes,
convexNodes,
deletingNodeIds: deletingNodeIds.current,
resolvedRealIdByClientRequest: resolvedRealIdByClientRequestRef.current,
pendingConnectionCreateIds: pendingConnectionCreatesRef.current,
preferLocalPositionNodeIds: preferLocalPositionNodeIdsRef.current,
pendingLocalPositionPins: pendingLocalPositionUntilConvexMatchesRef.current,
pendingMovePins: getPendingMovePinsFromLocalOps(canvasId as string),
});
resolvedRealIdByClientRequestRef.current =
reconciliation.inferredRealIdByClientRequest;
pendingLocalPositionUntilConvexMatchesRef.current =
reconciliation.nextPendingLocalPositionPins;
for (const nodeId of reconciliation.clearedPreferLocalPositionNodeIds) {
preferLocalPositionNodeIdsRef.current.delete(nodeId);
}
return reconciliation.nodes;
});
}, [canvasId, convexNodes, edges, storageUrlsById]);
useEffect(() => {
if (isDragging.current) return;
setNodes((nds) => withResolvedCompareData(nds, edges));
}, [edges]);
// ─── Future hook seam: node interactions ──────────────────────
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
for (const c of changes) {
if (c.type === "dimensions") {
if (c.resizing === true) {
isResizing.current = true;
} else if (c.resizing === false) {
isResizing.current = false;
}
}
}
const removedIds = new Set<string>();
for (const c of changes) {
if (c.type === "remove") {
removedIds.add(c.id);
}
}
setNodes((nds) => {
for (const c of changes) {
if (c.type === "position" && "id" in c) {
pendingLocalPositionUntilConvexMatchesRef.current.delete(c.id);
preferLocalPositionNodeIdsRef.current.add(c.id);
}
}
const adjustedChanges = adjustNodeDimensionChanges(changes, nds);
const nextNodes = applyNodeChanges(adjustedChanges, nds);
for (const change of adjustedChanges) {
if (change.type !== "dimensions") continue;
if (!change.dimensions) continue;
if (removedIds.has(change.id)) continue;
const prevNode = nds.find((node) => node.id === change.id);
const nextNode = nextNodes.find((node) => node.id === change.id);
void prevNode;
void nextNode;
if (change.resizing !== false) continue;
void runResizeNodeMutation({
nodeId: change.id as Id<"nodes">,
width: change.dimensions.width,
height: change.dimensions.height,
}).catch((error: unknown) => {
if (process.env.NODE_ENV !== "production") {
console.warn("[Canvas] resizeNode failed", error);
}
});
}
return nextNodes;
});
},
[runResizeNodeMutation],
);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
setEdges((eds) => applyEdgeChanges(changes, eds));
}, []);
const onFlowError = useCallback((id: string, error: string) => {
if (process.env.NODE_ENV === "production") return;
console.error("[ReactFlow error]", { canvasId, id, error });
}, [canvasId]);
const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => {
const previousHighlightedEdgeId = highlightedEdgeRef.current;
if (previousHighlightedEdgeId === edgeId) {
return;
}
setEdges((currentEdges) => {
let nextEdges = currentEdges;
if (previousHighlightedEdgeId) {
nextEdges = nextEdges.map((edge) =>
edge.id === previousHighlightedEdgeId
? {
...edge,
style: highlightedEdgeOriginalStyleRef.current,
}
: edge,
);
}
if (!edgeId) {
highlightedEdgeOriginalStyleRef.current = undefined;
return nextEdges;
}
const edgeToHighlight = nextEdges.find((edge) => edge.id === edgeId);
if (!edgeToHighlight || edgeToHighlight.className === "temp") {
highlightedEdgeOriginalStyleRef.current = undefined;
return nextEdges;
}
highlightedEdgeOriginalStyleRef.current = edgeToHighlight.style;
return nextEdges.map((edge) =>
edge.id === edgeId
? {
...edge,
style: {
...(edge.style ?? {}),
...EDGE_INTERSECTION_HIGHLIGHT_STYLE,
},
}
: edge,
);
});
highlightedEdgeRef.current = edgeId;
}, []);
const onNodeDrag = useCallback(
(_event: React.MouseEvent, node: RFNode) => {
const nodeCenter = getNodeCenterClientPosition(node.id);
if (!nodeCenter) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
const intersectedEdgeId = getIntersectedEdgeId(nodeCenter);
if (!intersectedEdgeId) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
const intersectedEdge = edges.find(
(edge) =>
edge.id === intersectedEdgeId &&
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id),
);
if (!intersectedEdge) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
if (
intersectedEdge.source === node.id ||
intersectedEdge.target === node.id
) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
const handles = NODE_HANDLE_MAP[node.type ?? ""];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
overlappedEdgeRef.current = intersectedEdge.id;
setHighlightedIntersectionEdge(intersectedEdge.id);
},
[edges, setHighlightedIntersectionEdge],
);
// Drag start / drag / drag stop stay together for the future node interaction hook.
const onNodeDragStart = useCallback(
(_event: ReactMouseEvent, _node: RFNode, draggedNodes: RFNode[]) => {
isDragging.current = true;
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
for (const n of draggedNodes) {
pendingLocalPositionUntilConvexMatchesRef.current.delete(n.id);
}
},
[setHighlightedIntersectionEdge],
);
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(
(edge) =>
edge.id === intersectedEdgeId &&
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id),
)
: undefined;
const splitHandles = NODE_HANDLE_MAP[primaryNode.type ?? ""];
const splitEligible =
intersectedEdge !== undefined &&
splitHandles !== undefined &&
intersectedEdge.source !== primaryNode.id &&
intersectedEdge.target !== primaryNode.id &&
hasHandleKey(splitHandles, "source") &&
hasHandleKey(splitHandles, "target");
if (draggedNodes.length > 1) {
for (const n of draggedNodes) {
const cid = clientRequestIdFromOptimisticNodeId(n.id);
if (cid) {
pendingMoveAfterCreateRef.current.set(cid, {
positionX: n.position.x,
positionY: n.position.y,
});
await syncPendingMoveForClientRequest(cid);
}
}
const realMoves = draggedNodes.filter((n) => !isOptimisticNodeId(n.id));
if (realMoves.length > 0) {
await runBatchMoveNodesMutation({
moves: realMoves.map((n) => ({
nodeId: n.id as Id<"nodes">,
positionX: n.position.x,
positionY: n.position.y,
})),
});
}
if (!splitEligible || !intersectedEdge) {
return;
}
const multiCid = clientRequestIdFromOptimisticNodeId(primaryNode.id);
let middleId = primaryNode.id as Id<"nodes">;
if (multiCid) {
const r = resolvedRealIdByClientRequestRef.current.get(multiCid);
if (!r) {
pendingEdgeSplitByClientRequestRef.current.set(multiCid, {
intersectedEdgeId: intersectedEdge.id as Id<"edges">,
sourceNodeId: intersectedEdge.source as Id<"nodes">,
targetNodeId: intersectedEdge.target as Id<"nodes">,
intersectedSourceHandle: normalizeHandle(
intersectedEdge.sourceHandle,
),
intersectedTargetHandle: normalizeHandle(
intersectedEdge.targetHandle,
),
middleSourceHandle: normalizeHandle(splitHandles.source),
middleTargetHandle: normalizeHandle(splitHandles.target),
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
return;
}
middleId = r;
}
await runSplitEdgeAtExistingNodeMutation({
canvasId,
splitEdgeId: intersectedEdge.id as Id<"edges">,
middleNodeId: middleId,
splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle),
newNodeSourceHandle: normalizeHandle(splitHandles.source),
newNodeTargetHandle: normalizeHandle(splitHandles.target),
});
return;
}
if (!splitEligible || !intersectedEdge) {
const cidSingle = clientRequestIdFromOptimisticNodeId(primaryNode.id);
if (cidSingle) {
pendingMoveAfterCreateRef.current.set(cidSingle, {
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
await syncPendingMoveForClientRequest(cidSingle);
} else {
await runMoveNodeMutation({
nodeId: primaryNode.id as Id<"nodes">,
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
}
return;
}
const singleCid = clientRequestIdFromOptimisticNodeId(primaryNode.id);
if (singleCid) {
const resolvedSingle =
resolvedRealIdByClientRequestRef.current.get(singleCid);
if (!resolvedSingle) {
pendingMoveAfterCreateRef.current.set(singleCid, {
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
pendingEdgeSplitByClientRequestRef.current.set(singleCid, {
intersectedEdgeId: intersectedEdge.id as Id<"edges">,
sourceNodeId: intersectedEdge.source as Id<"nodes">,
targetNodeId: intersectedEdge.target as Id<"nodes">,
intersectedSourceHandle: normalizeHandle(
intersectedEdge.sourceHandle,
),
intersectedTargetHandle: normalizeHandle(
intersectedEdge.targetHandle,
),
middleSourceHandle: normalizeHandle(splitHandles.source),
middleTargetHandle: normalizeHandle(splitHandles.target),
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
await syncPendingMoveForClientRequest(singleCid);
return;
}
await runSplitEdgeAtExistingNodeMutation({
canvasId,
splitEdgeId: intersectedEdge.id as Id<"edges">,
middleNodeId: resolvedSingle,
splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle),
newNodeSourceHandle: normalizeHandle(splitHandles.source),
newNodeTargetHandle: normalizeHandle(splitHandles.target),
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
pendingMoveAfterCreateRef.current.delete(singleCid);
return;
}
await runSplitEdgeAtExistingNodeMutation({
canvasId,
splitEdgeId: intersectedEdge.id as Id<"edges">,
middleNodeId: primaryNode.id as Id<"nodes">,
splitSourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
splitTargetHandle: normalizeHandle(intersectedEdge.targetHandle),
newNodeSourceHandle: normalizeHandle(splitHandles.source),
newNodeTargetHandle: normalizeHandle(splitHandles.target),
positionX: primaryNode.position.x,
positionY: primaryNode.position.y,
});
} catch (error) {
console.error("[Canvas edge intersection split failed]", {
canvasId,
nodeId: primaryNode?.id ?? null,
nodeType: primaryNode?.type ?? null,
intersectedEdgeId,
error: String(error),
});
} finally {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
isDragging.current = false;
}
})();
},
[
canvasId,
edges,
runBatchMoveNodesMutation,
runMoveNodeMutation,
setHighlightedIntersectionEdge,
runSplitEdgeAtExistingNodeMutation,
syncPendingMoveForClientRequest,
],
);
// ─── Future hook seam: connections ────────────────────────────
const onConnect = useCallback(
(connection: Connection) => {
const validationError = validateCanvasConnection(connection, nodes, edges);
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
if (!connection.source || !connection.target) return;
void runCreateEdgeMutation({
canvasId,
sourceNodeId: connection.source as Id<"nodes">,
targetNodeId: connection.target as Id<"nodes">,
sourceHandle: connection.sourceHandle ?? undefined,
targetHandle: connection.targetHandle ?? undefined,
});
},
[canvasId, edges, nodes, runCreateEdgeMutation, showConnectionRejectedToast],
);
const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => {
if (isReconnectDragActiveRef.current) return;
if (connectionState.isValid === true) return;
const fromNode = connectionState.fromNode;
const fromHandle = connectionState.fromHandle;
if (!fromNode || !fromHandle) return;
const pt = getConnectEndClientPoint(event);
if (!pt) return;
const flow = screenToFlowPosition({ x: pt.x, y: pt.y });
setConnectionDropMenu({
screenX: pt.x,
screenY: pt.y,
flowX: flow.x,
flowY: flow.y,
fromNodeId: fromNode.id as Id<"nodes">,
fromHandleId: fromHandle.id ?? undefined,
fromHandleType: fromHandle.type,
});
},
[screenToFlowPosition],
);
const handleConnectionDropPick = useCallback(
(template: CanvasNodeTemplate) => {
const ctx = connectionDropMenuRef.current;
if (!ctx) return;
const fromNode = nodesRef.current.find((node) => node.id === ctx.fromNodeId);
if (!fromNode) {
showConnectionRejectedToast("unknown-node");
return;
}
const defaults = NODE_DEFAULTS[template.type] ?? {
width: 200,
height: 100,
data: {},
};
const clientRequestId = crypto.randomUUID();
pendingConnectionCreatesRef.current.add(clientRequestId);
const handles = NODE_HANDLE_MAP[template.type];
const width = template.width ?? defaults.width;
const height = template.height ?? defaults.height;
const data = {
...defaults.data,
...(template.defaultData as Record<string, unknown>),
canvasId,
};
const base = {
canvasId,
type: template.type,
positionX: ctx.flowX,
positionY: ctx.flowY,
width,
height,
data,
clientRequestId,
};
const settle = (realId: Id<"nodes">) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error("[Canvas] settle syncPendingMove failed", error);
},
);
};
if (ctx.fromHandleType === "source") {
const validationError = validateCanvasConnectionByType({
sourceType: fromNode.type ?? "",
targetType: template.type,
targetNodeId: `__pending_${template.type}_${Date.now()}`,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeFromSourceOnlineOnly({
...base,
sourceNodeId: ctx.fromNodeId,
sourceHandle: ctx.fromHandleId,
targetHandle: handles?.target ?? undefined,
})
.then((realId) => {
if (isOptimisticNodeId(realId as string)) {
return;
}
resolvedRealIdByClientRequestRef.current.set(
clientRequestId,
realId,
);
settle(realId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeFromSource failed", error);
});
} else {
const validationError = validateCanvasConnectionByType({
sourceType: template.type,
targetType: fromNode.type ?? "",
targetNodeId: fromNode.id,
edges: edgesRef.current,
});
if (validationError) {
showConnectionRejectedToast(validationError);
return;
}
void runCreateNodeWithEdgeToTargetOnlineOnly({
...base,
targetNodeId: ctx.fromNodeId,
sourceHandle: handles?.source ?? undefined,
targetHandle: ctx.fromHandleId,
})
.then((realId) => {
if (isOptimisticNodeId(realId as string)) {
return;
}
resolvedRealIdByClientRequestRef.current.set(
clientRequestId,
realId,
);
settle(realId);
setEdgeSyncNonce((n) => n + 1);
})
.catch((error) => {
pendingConnectionCreatesRef.current.delete(clientRequestId);
console.error("[Canvas] createNodeWithEdgeToTarget failed", error);
});
}
},
[
canvasId,
runCreateNodeWithEdgeFromSourceOnlineOnly,
runCreateNodeWithEdgeToTargetOnlineOnly,
showConnectionRejectedToast,
syncPendingMoveForClientRequest,
],
);
// ─── Future hook seam: drop flows ─────────────────────────────
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
const hasFiles = event.dataTransfer.types.includes("Files");
event.dataTransfer.dropEffect = hasFiles ? "copy" : "move";
}, []);
const onDrop = useCallback(
async (event: React.DragEvent) => {
event.preventDefault();
const rawData = event.dataTransfer.getData(
CANVAS_NODE_DND_MIME,
);
if (!rawData) {
const hasFiles = event.dataTransfer.files && event.dataTransfer.files.length > 0;
if (hasFiles) {
if (!isSyncOnline) {
notifyOfflineUnsupported("Upload per Drag-and-drop");
return;
}
const file = event.dataTransfer.files[0];
if (file.type.startsWith("image/")) {
try {
let dimensions: { width: number; height: number } | undefined;
try {
dimensions = await getImageDimensions(file);
} catch {
dimensions = undefined;
}
const uploadUrl = await generateUploadUrl();
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Upload failed");
}
const { storageId } = (await result.json()) as { storageId: string };
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
const clientRequestId = crypto.randomUUID();
void runCreateNodeOnlineOnly({
canvasId,
type: "image",
positionX: position.x,
positionY: position.y,
width: NODE_DEFAULTS.image.width,
height: NODE_DEFAULTS.image.height,
data: {
storageId,
filename: file.name,
mimeType: file.type,
...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
canvasId,
},
clientRequestId,
}).then((realId) => {
void syncPendingMoveForClientRequest(
clientRequestId,
realId,
).catch((error: unknown) => {
console.error(
"[Canvas] drop createNode syncPendingMove failed",
error,
);
});
});
} catch (err) {
console.error("Failed to upload dropped file:", err);
toast.error(t('canvas.uploadFailed'), err instanceof Error ? err.message : undefined);
}
return;
}
}
return;
}
// Support both plain type string (sidebar) and JSON payload (browser panels)
let nodeType: CanvasNodeType | null = null;
let payloadData: Record<string, unknown> | undefined;
try {
const parsed = JSON.parse(rawData);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof (parsed as { type?: unknown }).type === "string" &&
isCanvasNodeType((parsed as { type: string }).type)
) {
nodeType = (parsed as { type: CanvasNodeType }).type;
payloadData = parsed.data;
}
} catch {
if (isCanvasNodeType(rawData)) {
nodeType = rawData;
}
}
if (!nodeType) {
toast.warning("Node-Typ nicht verfuegbar", "Unbekannter Node konnte nicht erstellt werden.");
return;
}
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const defaults = NODE_DEFAULTS[nodeType] ?? {
width: 200,
height: 100,
data: {},
};
const clientRequestId = crypto.randomUUID();
void runCreateNodeOnlineOnly({
canvasId,
type: nodeType,
positionX: position.x,
positionY: position.y,
width: defaults.width,
height: defaults.height,
data: { ...defaults.data, ...payloadData, canvasId },
clientRequestId,
}).then((realId) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error(
"[Canvas] createNode syncPendingMove failed",
error,
);
},
);
});
},
[
screenToFlowPosition,
t,
canvasId,
generateUploadUrl,
isSyncOnline,
runCreateNodeOnlineOnly,
notifyOfflineUnsupported,
syncPendingMoveForClientRequest,
],
);
const canvasSyncContextValue = useMemo(
() => ({
queueNodeDataUpdate: runUpdateNodeDataMutation,
queueNodeResize: runResizeNodeMutation,
status: {
pendingCount: pendingSyncCount,
isSyncing,
isOffline: !isSyncOnline,
},
}),
[isSyncOnline, isSyncing, pendingSyncCount, runResizeNodeMutation, runUpdateNodeDataMutation],
);
// ─── Future hook seam: render assembly ────────────────────────
if (convexNodes === undefined || convexEdges === undefined) {
return (
<div className="flex h-full w-full items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Canvas lädt</span>
</div>
</div>
);
}
return (
<CanvasSyncProvider value={canvasSyncContextValue}>
<CanvasPresetsProvider enabled={hasPresetAwareNodes}>
<CanvasPlacementProvider
canvasId={canvasId}
createNode={runCreateNodeOnlineOnly}
createNodeWithEdgeSplit={runCreateNodeWithEdgeSplitOnlineOnly}
createNodeWithEdgeFromSource={runCreateNodeWithEdgeFromSourceOnlineOnly}
createNodeWithEdgeToTarget={runCreateNodeWithEdgeToTargetOnlineOnly}
onCreateNodeSettled={({ clientRequestId, realId }) => {
void syncPendingMoveForClientRequest(clientRequestId, realId).catch(
(error: unknown) => {
console.error(
"[Canvas] onCreateNodeSettled syncPendingMove failed",
error,
);
},
);
}}
>
<AssetBrowserTargetContext.Provider value={assetBrowserTargetApi}>
<div className="relative h-full w-full">
<CanvasToolbar
canvasName={canvas?.name}
activeTool={navTool}
onToolChange={handleNavToolChange}
/>
<CanvasAppMenu canvasId={canvasId} />
<CanvasCommandPalette />
<CanvasConnectionDropMenu
state={connectionDropMenu}
onClose={() => setConnectionDropMenu(null)}
onPick={handleConnectionDropPick}
/>
{scissorsMode ? (
<div className="pointer-events-none absolute top-14 left-1/2 z-50 max-w-[min(100%-2rem,28rem)] -translate-x-1/2 rounded-lg bg-popover/95 px-3 py-1.5 text-center text-xs text-popover-foreground shadow-md ring-1 ring-foreground/10">
Scherenmodus Kante anklicken oder ziehen zum Durchtrennen ·{" "}
<span className="whitespace-nowrap">Esc oder K beenden</span> · Mitte/Rechtsklick zum
Verschieben
</div>
) : null}
{scissorStrokePreview && scissorStrokePreview.length > 1 ? (
<svg
className="pointer-events-none fixed inset-0 z-60 overflow-visible"
aria-hidden
>
<polyline
fill="none"
stroke="var(--primary)"
strokeWidth={2}
strokeDasharray="6 4"
opacity={0.85}
points={scissorStrokePreview
.map((p) => `${p.x},${p.y}`)
.join(" ")}
/>
</svg>
) : null}
<div
className="relative h-full min-h-0 w-full"
onPointerDownCapture={
scissorsMode ? onScissorsFlowPointerDownCapture : undefined
}
>
<ReactFlow
nodes={nodes}
edges={edges}
onlyRenderVisibleElements
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
connectionLineComponent={CustomConnectionLine}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onBeforeDelete={onBeforeDelete}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onEdgeClick={scissorsMode ? onEdgeClickScissors : undefined}
onError={onFlowError}
onDragOver={onDragOver}
onDrop={onDrop}
fitView
minZoom={CANVAS_MIN_ZOOM}
snapToGrid={false}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
nodesConnectable={!scissorsMode}
panOnDrag={flowPanOnDrag}
selectionOnDrag={flowSelectionOnDrag}
panActivationKeyCode="Space"
proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className={cn("bg-background", scissorsMode && "canvas-scissors-mode")}
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
<MiniMap
className="bg-card! border! shadow-sm! rounded-lg!"
nodeColor={getMiniMapNodeColor}
nodeStrokeColor={getMiniMapNodeStrokeColor}
maskColor="rgba(0, 0, 0, 0.1)"
/>
</ReactFlow>
</div>
</div>
</AssetBrowserTargetContext.Provider>
</CanvasPlacementProvider>
</CanvasPresetsProvider>
</CanvasSyncProvider>
);
}
interface CanvasProps {
canvasId: Id<"canvases">;
}
export default function Canvas({ canvasId }: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner canvasId={canvasId} />
</ReactFlowProvider>
);
}