"use client"; import { useCallback, useEffect, 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 { useMutation } from "convex/react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { isAdjustmentPresetNodeType, isCanvasNodeType, type CanvasNodeType, } from "@/lib/canvas-node-types"; import { nodeTypes } from "./node-types"; import { 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, clientRequestIdFromOptimisticNodeId, DEFAULT_EDGE_OPTIONS, EDGE_INTERSECTION_HIGHLIGHT_STYLE, getConnectEndClientPoint, getMiniMapNodeColor, getMiniMapNodeStrokeColor, getNodeCenterClientPosition, getIntersectedEdgeId, getPendingRemovedEdgeIdsFromLocalOps, getPendingMovePinsFromLocalOps, hasHandleKey, isEditableKeyboardTarget, isOptimisticEdgeId, isOptimisticNodeId, normalizeHandle, 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 { useCanvasData } from "./use-canvas-data"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; interface CanvasInnerProps { canvasId: Id<"canvases">; } 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, }); const generateUploadUrl = useMutation(api.storage.generateUploadUrl); const convexNodeIdsSnapshotForEdgeCarryRef = useRef(new Set()); const [assetBrowserTargetNodeId, setAssetBrowserTargetNodeId] = useState< string | null >(null); const [edgeSyncNonce, setEdgeSyncNonce] = useState(0); const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); const edgesRef = useRef(edges); edgesRef.current = edges; const deletingNodeIds = useRef>(new Set()); const { status: { pendingSyncCount, isSyncing, isSyncOnline }, refs: { pendingMoveAfterCreateRef, resolvedRealIdByClientRequestRef, pendingEdgeSplitByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, }, actions: { createNode: runCreateNodeOnlineOnly, createNodeWithEdgeFromSource: runCreateNodeWithEdgeFromSourceOnlineOnly, createNodeWithEdgeToTarget: runCreateNodeWithEdgeToTargetOnlineOnly, createNodeWithEdgeSplit: runCreateNodeWithEdgeSplitOnlineOnly, moveNode: runMoveNodeMutation, batchMoveNodes: runBatchMoveNodesMutation, resizeNode: runResizeNodeMutation, updateNodeData: runUpdateNodeDataMutation, batchRemoveNodes: runBatchRemoveNodesMutation, createEdge: runCreateEdgeMutation, removeEdge: runRemoveEdgeMutation, splitEdgeAtExistingNode: runSplitEdgeAtExistingNodeMutation, syncPendingMoveForClientRequest, notifyOfflineUnsupported, }, } = useCanvasSyncEngine({ canvasId, setNodes, setEdges, edgesRef, setAssetBrowserTargetNodeId, setEdgeSyncNonce, deletingNodeIds, }); const hasPresetAwareNodes = useMemo( () => nodes.some((node) => isAdjustmentPresetNodeType(node.type ?? "")) || (convexNodes ?? []).some((node) => isAdjustmentPresetNodeType(node.type)), [convexNodes, nodes], ); // ─── Future hook seam: render composition + shared local flow state ───── 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 assetBrowserTargetApi: AssetBrowserTargetApi = useMemo( () => ({ targetNodeId: assetBrowserTargetNodeId, openForNode: (nodeId: string) => setAssetBrowserTargetNodeId(nodeId), close: () => setAssetBrowserTargetNodeId(null), }), [assetBrowserTargetNodeId], ); const pendingRemovedEdgeIds = useMemo( () => { void convexEdges; void edgeSyncNonce; return getPendingRemovedEdgeIdsFromLocalOps(canvasId as string); }, [canvasId, convexEdges, edgeSyncNonce], ); const pendingMovePins = useMemo( () => { void convexNodes; void edgeSyncNonce; return getPendingMovePinsFromLocalOps(canvasId as string); }, [canvasId, convexNodes, edgeSyncNonce], ); 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 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); }, }); useCanvasFlowReconciliation({ convexNodes, convexEdges, storageUrlsById, themeMode: resolvedTheme === "dark" ? "dark" : "light", pendingRemovedEdgeIds, pendingMovePins, setNodes, setEdges, refs: { nodesRef, edgesRef, deletingNodeIds, convexNodeIdsSnapshotForEdgeCarryRef, resolvedRealIdByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging, isResizing, }, }); 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(); 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; }); }, [pendingLocalPositionUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, 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); } }, [pendingLocalPositionUntilConvexMatchesRef, 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, pendingEdgeSplitByClientRequestRef, pendingMoveAfterCreateRef, resolvedRealIdByClientRequestRef, 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( (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, pendingConnectionCreatesRef, resolvedRealIdByClientRequestRef, 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 | 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 (
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 ( ); }