"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 { 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"; import { isAdjustmentPresetNodeType, isCanvasNodeType, type CanvasNodeType, } from "@/lib/canvas-node-types"; import { nodeTypes } from "./node-types"; import { convexNodeDocWithMergedStorageUrl, convexNodeToRF, convexEdgeToRF, convexEdgeToRFWithSourceGlow, 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 { applyPinnedNodePositions, applyPinnedNodePositionsReadOnly, CANVAS_MIN_ZOOM, clientRequestIdFromOptimisticEdgeId, clientRequestIdFromOptimisticNodeId, createCanvasOpId, DEFAULT_EDGE_OPTIONS, EDGE_INTERSECTION_HIGHLIGHT_STYLE, getConnectEndClientPoint, getMiniMapNodeColor, getMiniMapNodeStrokeColor, getNodeCenterClientPosition, getIntersectedEdgeId, getPendingRemovedEdgeIdsFromLocalOps, getPendingMovePinsFromLocalOps, hasHandleKey, inferPendingConnectionNodeHandoff, isEditableKeyboardTarget, isOptimisticEdgeId, isOptimisticNodeId, mergeNodesPreservingLocalState, normalizeHandle, OPTIMISTIC_EDGE_PREFIX, OPTIMISTIC_NODE_PREFIX, positionsMatchPin, type PendingEdgeSplit, rfEdgeConnectionSignature, withResolvedCompareData, } from "./canvas-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 { 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 { 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) : 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 { 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 { data: session, isPending: isSessionPending } = authClient.useSession(); const { isLoading: isAuthLoading, isAuthenticated } = useConvexAuth(); const shouldSkipCanvasQueries = isSessionPending || isAuthLoading || !isAuthenticated; useEffect(() => { if (process.env.NODE_ENV === "production") return; if (!isAuthLoading && !isAuthenticated) { console.warn("[Canvas debug] mounted without Convex auth", { canvasId }); } }, [canvasId, isAuthLoading, isAuthenticated]); useEffect(() => { if (process.env.NODE_ENV === "production") return; if (isAuthLoading || isSessionPending) return; console.info("[Canvas auth state]", { canvasId, convex: { isAuthenticated, shouldSkipCanvasQueries, }, session: { hasUser: Boolean(session?.user), email: session?.user?.email ?? null, }, }); }, [ canvasId, isAuthLoading, isAuthenticated, isSessionPending, session?.user, shouldSkipCanvasQueries, ]); // ─── Convex Realtime Queries ─────────────────────────────────── const convexNodes = useQuery( api.nodes.list, shouldSkipCanvasQueries ? "skip" : { canvasId }, ); const convexEdges = useQuery( api.edges.list, shouldSkipCanvasQueries ? "skip" : { canvasId }, ); const storageIdsForCanvas = useMemo(() => { if (!convexNodes) { return [] as Id<"_storage">[]; } return [...new Set( convexNodes.flatMap((node) => { const data = node.data as Record | undefined; return typeof data?.storageId === "string" && data.storageId.length > 0 ? [data.storageId as Id<"_storage">] : []; }), )].sort(); }, [convexNodes]); const storageIdsForCanvasKey = storageIdsForCanvas.join(","); const stableStorageIdsForCanvasRef = useRef(storageIdsForCanvas); if (stableStorageIdsForCanvasRef.current.join(",") !== storageIdsForCanvasKey) { stableStorageIdsForCanvasRef.current = storageIdsForCanvas; } const resolveStorageUrlsForCanvas = useMutation(api.storage.batchGetUrlsForCanvas); const [storageUrlsById, setStorageUrlsById] = useState>(); const canvas = useQuery( api.canvases.get, shouldSkipCanvasQueries ? "skip" : { canvasId }, ); useEffect(() => { const requestedStorageIds = stableStorageIdsForCanvasRef.current; if (shouldSkipCanvasQueries || requestedStorageIds.length === 0) { setStorageUrlsById(undefined); return; } let cancelled = false; void resolveStorageUrlsForCanvas({ canvasId, storageIds: requestedStorageIds, }) .then((result) => { if (!cancelled) { setStorageUrlsById(result); } }) .catch((error: unknown) => { if (!cancelled) { console.warn("[Canvas] failed to resolve storage URLs", { canvasId, storageIdCount: requestedStorageIds.length, message: error instanceof Error ? error.message : String(error), }); } }); return () => { cancelled = true; }; }, [ canvasId, resolveStorageUrlsForCanvas, shouldSkipCanvasQueries, storageIdsForCanvasKey, ]); // ─── 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(), ); const pendingResizeAfterCreateRef = useRef( new Map(), ); const pendingDataAfterCreateRef = useRef(new Map()); const resolvedRealIdByClientRequestRef = useRef(new Map>()); const pendingCreatePromiseByClientRequestRef = useRef( new Map>>(), ); const pendingEdgeSplitByClientRequestRef = useRef( new Map(), ); const pendingDeleteAfterCreateClientRequestIdsRef = useRef(new Set()); /** Connection-Drop → neue Node: erlaubt Carry-over der Kante in der Rollback-Lücke (ohne Phantom nach Fehler). */ const pendingConnectionCreatesRef = useRef(new Set()); /** Nach create+drag: Convex liefert oft noch Erstellkoordinaten, bis `moveNode` committed — bis dahin Position pinnen. */ const pendingLocalPositionUntilConvexMatchesRef = useRef( new Map(), ); /** Vorheriger Stand von api.nodes.list-IDs — um genau die neu eingetretene Node-ID vor Mutation-.then zu erkennen. */ const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set()); const syncPendingMoveForClientRequestRef = useRef< (clientRequestId: string | undefined, realId?: Id<"nodes">) => Promise >(async () => {}); const enqueueSyncMutationRef = useRef< ( type: TType, payload: CanvasSyncOpPayloadByType[TType], ) => Promise >(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()); 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 syntheticEdge: Doc<"edges"> = { _id: tempEdgeId, _creationTime: Date.now(), canvasId: args.canvasId, sourceNodeId: args.sourceNodeId as Id<"nodes">, 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 syntheticEdge: Doc<"edges"> = { _id: tempEdgeId, _creationTime: Date.now(), canvasId: args.canvasId, sourceNodeId: tempNodeId, targetNodeId: args.targetNodeId as Id<"nodes">, 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, }); if (edgeList === undefined) 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: args.sourceNodeId, targetNodeId: args.targetNodeId, 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([]); const [edges, setEdges] = useState([]); 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>, ): Promise> => { 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[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 => { 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[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[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[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[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[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[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[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[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 ( 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 => { 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 => { 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[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[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 => { 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; // ─── Lokaler State (für flüssiges Dragging) ─────────────────── const nodesRef = useRef(nodes); nodesRef.current = nodes; const [connectionDropMenu, setConnectionDropMenu] = useState(null); const connectionDropMenuRef = useRef(null); connectionDropMenuRef.current = connectionDropMenu; const [scissorsMode, setScissorsMode] = useState(false); const [scissorStrokePreview, setScissorStrokePreview] = useState< { x: number; y: number }[] | null >(null); const [navTool, setNavTool] = useState("select"); useCanvasLocalSnapshotPersistence({ 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>(new Set()); // Delete Edge on Drop const edgeReconnectSuccessful = useRef(true); const isReconnectDragActiveRef = useRef(false); const overlappedEdgeRef = useRef(null); const highlightedEdgeRef = useRef(null); const highlightedEdgeOriginalStyleRef = useRef( 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); }, }); // ─── Convex → Lokaler State Sync ────────────────────────────── /** * 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 prevConvexSnap = convexNodeIdsSnapshotForEdgeCarryRef.current; const currentConvexIdList: string[] = convexNodes !== undefined ? convexNodes.map((n: Doc<"nodes">) => n._id as string) : []; const currentConvexIdSet = new Set(currentConvexIdList); const newlyAppearedIds: string[] = []; for (const id of currentConvexIdList) { if (!prevConvexSnap.has(id)) newlyAppearedIds.push(id); } const tempEdges = prev.filter((e) => e.className === "temp"); const pendingRemovedEdgeIds = getPendingRemovedEdgeIdsFromLocalOps( canvasId as string, ); const sourceTypeByNodeId = convexNodes !== undefined ? new Map( convexNodes.map((n: Doc<"nodes">) => [n._id as string, n.type as string]), ) : undefined; const glowMode = resolvedTheme === "dark" ? "dark" : "light"; const mapped = convexEdges .filter((edge: Doc<"edges">) => !pendingRemovedEdgeIds.has(edge._id as string)) .map((edge: Doc<"edges">) => sourceTypeByNodeId ? convexEdgeToRFWithSourceGlow( edge, sourceTypeByNodeId.get(edge.sourceNodeId), glowMode, ) : convexEdgeToRF(edge), ); const mappedSignatures = new Set(mapped.map(rfEdgeConnectionSignature)); const convexNodeIds = convexNodes !== undefined ? 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; }; /** Wenn Mutation-.then noch nicht lief: echte ID aus Delta (eine neue Node) + gleiche clientRequestId wie Kante. */ const resolveEndpointWithInference = ( nodeId: string, edge: RFEdge, ): 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); if (edgeCr === null || edgeCr !== nodeCr) return base; if (!pendingConnectionCreatesRef.current.has(nodeCr)) return base; if (newlyAppearedIds.length !== 1) return base; const inferred = newlyAppearedIds[0]; resolvedRealIdByClientRequestRef.current.set( nodeCr, inferred as Id<"nodes">, ); return inferred; }; 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; return false; }; const optimisticEndpointHasPendingCreate = (nodeId: string): boolean => { if (!isOptimisticNodeId(nodeId)) return false; const cr = clientRequestIdFromOptimisticNodeId(nodeId); return ( cr !== null && pendingConnectionCreatesRef.current.has(cr) ); }; const shouldCarryOptimisticEdge = ( original: RFEdge, remapped: RFEdge, ): boolean => { if (mappedSignatures.has(rfEdgeConnectionSignature(remapped))) { return false; } const sourceOk = endpointUsable(remapped.source); const targetOk = endpointUsable(remapped.target); if (sourceOk && targetOk) return true; if (!pendingConnectionCreatesRef.current.size) { return false; } if ( sourceOk && optimisticEndpointHasPendingCreate(original.target) ) { return true; } if ( targetOk && optimisticEndpointHasPendingCreate(original.source) ) { return true; } return false; }; const carriedOptimistic: RFEdge[] = []; for (const e of prev) { if (e.className === "temp") continue; if (!isOptimisticEdgeId(e.id)) continue; const remapped: RFEdge = { ...e, source: resolveEndpointWithInference(e.source, e), target: resolveEndpointWithInference(e.target, e), }; if (!shouldCarryOptimisticEdge(e, remapped)) continue; carriedOptimistic.push(remapped); } if (convexNodes !== undefined) { convexNodeIdsSnapshotForEdgeCarryRef.current = currentConvexIdSet; } /** Erst löschen, wenn Convex die neue Kante geliefert hat — sonst kurzes Fenster: pending=0, Kanten-Query noch alt, Carry schlägt fehl. */ for (const cr of [...pendingConnectionCreatesRef.current]) { const realId = resolvedRealIdByClientRequestRef.current.get(cr); if (realId === undefined) continue; const nodePresent = convexNodes !== undefined && convexNodes.some((n: Doc<"nodes">) => n._id === realId); const edgeTouchesNewNode = convexEdges.some( (e: Doc<"edges">) => e.sourceNodeId === realId || e.targetNodeId === realId, ); if (nodePresent && edgeTouchesNewNode) { pendingConnectionCreatesRef.current.delete(cr); } } return [...mapped, ...carriedOptimistic, ...tempEdges]; }); }, [canvasId, convexEdges, convexNodes, resolvedTheme, edgeSyncNonce]); useLayoutEffect(() => { if (!convexNodes || isResizing.current) return; setNodes((previousNodes) => { inferPendingConnectionNodeHandoff( previousNodes, convexNodes, pendingConnectionCreatesRef.current, resolvedRealIdByClientRequestRef.current, ); /** 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]), ); const enriched = convexNodes.map((node: Doc<"nodes">) => convexNodeDocWithMergedStorageUrl( node, storageUrlsById, prevDataById, ), ); const incomingNodes = withResolvedCompareData( enriched.map(convexNodeToRF), edges, ); // Nodes, die gerade optimistisch gelöscht werden, nicht wiederherstellen const filteredIncoming = deletingNodeIds.current.size > 0 ? incomingNodes.filter((node) => !deletingNodeIds.current.has(node.id)) : incomingNodes; const merged = applyPinnedNodePositions( mergeNodesPreservingLocalState( previousNodes, filteredIncoming, resolvedRealIdByClientRequestRef.current, preferLocalPositionNodeIdsRef.current, ), pendingLocalPositionUntilConvexMatchesRef.current, ); const mergedWithOpPins = applyPinnedNodePositionsReadOnly( merged, getPendingMovePinsFromLocalOps(canvasId as string), ); /** Nicht am Drag-Ende leeren (moveNode läuft oft async): solange Convex alt ist, Eintrag behalten und erst bei übereinstimmendem Snapshot entfernen. */ const incomingById = new Map( filteredIncoming.map((n) => [n.id, n]), ); for (const n of mergedWithOpPins) { if (!preferLocalPositionNodeIdsRef.current.has(n.id)) continue; const inc = incomingById.get(n.id); if (!inc) continue; if ( positionsMatchPin(n.position, { x: inc.position.x, y: inc.position.y, }) ) { preferLocalPositionNodeIdsRef.current.delete(n.id); } } return mergedWithOpPins; }); }, [canvasId, convexNodes, edges, storageUrlsById]); useEffect(() => { if (isDragging.current) return; setNodes((nds) => withResolvedCompareData(nds, edges)); }, [edges]); // ─── Node Changes (Drag, Select, Remove) ───────────────────── 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(); 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 → Lock ──────────────────────────────────────── 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], ); // ─── 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( (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, ], ); // ─── Neue Verbindung → Convex Edge ──────────────────────────── 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( (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), 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, ], ); 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 | 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], ); // ─── Loading State ──────────────────────────────────────────── if (convexNodes === undefined || convexEdges === undefined) { return (
Canvas lädt…
); } return ( { void syncPendingMoveForClientRequest(clientRequestId, realId).catch( (error: unknown) => { console.error( "[Canvas] onCreateNodeSettled syncPendingMove failed", error, ); }, ); }} >
setConnectionDropMenu(null)} onPick={handleConnectionDropPick} /> {scissorsMode ? (
Scherenmodus — Kante anklicken oder ziehen zum Durchtrennen ·{" "} Esc oder K beenden · Mitte/Rechtsklick zum Verschieben
) : null} {scissorStrokePreview && scissorStrokePreview.length > 1 ? ( `${p.x},${p.y}`) .join(" ")} /> ) : null}
); } interface CanvasProps { canvasId: Id<"canvases">; } export default function Canvas({ canvasId }: CanvasProps) { return ( ); }