"use client"; import { type CSSProperties, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useTheme } from "next-themes"; import { useTranslations } from "next-intl"; import { ReactFlow, ReactFlowProvider, Background, Controls, MiniMap, applyEdgeChanges, useReactFlow, type Node as RFNode, type Edge as RFEdge, type EdgeChange, BackgroundVariant, } from "@xyflow/react"; import { cn } from "@/lib/utils"; import "@xyflow/react/dist/style.css"; import { type CanvasConnectionValidationReason, } 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, } from "@/lib/canvas-node-types"; import { nodeTypes } from "./node-types"; 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, } from "@/components/canvas/canvas-connection-drop-menu"; import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context"; import { CanvasGraphProvider } from "@/components/canvas/canvas-graph-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 { CANVAS_MIN_ZOOM, DEFAULT_EDGE_OPTIONS, getSingleCharacterHotkey, getMiniMapNodeColor, getMiniMapNodeStrokeColor, getPendingRemovedEdgeIdsFromLocalOps, getPendingMovePinsFromLocalOps, isEditableKeyboardTarget, withResolvedCompareData, } from "./canvas-helpers"; import { useGenerationFailureWarnings } from "./canvas-generation-failures"; import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; import { useCanvasNodeInteractions } from "./use-canvas-node-interactions"; import { useCanvasConnections } from "./use-canvas-connections"; import { useCanvasDrop } from "./use-canvas-drop"; import { useCanvasScissors } from "./canvas-scissors"; import { type DefaultEdgeInsertAnchor } from "./edges/default-edge"; import { CanvasSyncProvider } from "./canvas-sync-context"; import { useCanvasData } from "./use-canvas-data"; import { useCanvasEdgeInsertions } from "./use-canvas-edge-insertions"; import { useCanvasEdgeTypes } from "./use-canvas-edge-types"; 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">; } const EDGE_INSERT_REFLOW_SETTLE_MS = 997; 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 registerUploadedImageMedia = useMutation(api.storage.registerUploadedImageMedia); const runSwapMixerInputsMutation = useMutation(api.edges.swapMixerInputs); 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); const deletingNodeIds = useRef>(new Set()); const { status: { pendingSyncCount, isSyncing, isSyncOnline }, refs: { pendingMoveAfterCreateRef, resolvedRealIdByClientRequestRef, pendingEdgeSplitByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, pendingLocalNodeSizeUntilConvexMatchesRef, 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); const [scissorsMode, setScissorsMode] = useState(false); const [isEdgeInsertReflowing, setIsEdgeInsertReflowing] = 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 canvasGraphNodes = useMemo( () => nodes.map((node) => ({ id: node.id, type: node.type ?? "", data: node.data, })), [nodes], ); const canvasGraphEdges = useMemo( () => edges.map((edge) => ({ source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle ?? undefined, targetHandle: edge.targetHandle ?? undefined, className: edge.className ?? undefined, })), [edges], ); 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 = getSingleCharacterHotkey(e); 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); useEffect(() => { edgesRef.current = edges; }, [edges]); useEffect(() => { nodesRef.current = nodes; }, [nodes]); useEffect(() => { scissorsModeRef.current = scissorsMode; }, [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); useGenerationFailureWarnings(t, convexNodes); const { onEdgeClickScissors, onScissorsFlowPointerDownCapture } = useCanvasScissors({ scissorsMode, scissorsModeRef, edgesRef, setScissorsMode, setNavTool, setScissorStrokePreview, runRemoveEdgeMutation, }); const { onBeforeDelete, onNodesDelete, onEdgesDelete } = useCanvasDeleteHandlers({ t, canvasId, nodes, edges, nodesRef, edgesRef, deletingNodeIds, setAssetBrowserTargetNodeId, runBatchRemoveNodesMutation, runCreateEdgeMutation, runRemoveEdgeMutation, }); const { connectionDropMenu, closeConnectionDropMenu, handleConnectionDropPick, onConnect, onConnectStart, onConnectEnd, onReconnectStart, onReconnect, onReconnectEnd, } = useCanvasConnections({ canvasId, nodes, edges, nodesRef, edgesRef, edgeReconnectSuccessful, isReconnectDragActiveRef, pendingConnectionCreatesRef, resolvedRealIdByClientRequestRef, setEdges, setEdgeSyncNonce, screenToFlowPosition, syncPendingMoveForClientRequest, runCreateEdgeMutation, runSplitEdgeAtExistingNodeMutation, runRemoveEdgeMutation, runSwapMixerInputsMutation, runCreateNodeWithEdgeFromSourceOnlineOnly, runCreateNodeWithEdgeToTargetOnlineOnly, showConnectionRejectedToast, }); const applyLocalEdgeInsertMoves = useCallback( ( moves: { nodeId: Id<"nodes">; positionX: number; positionY: number; }[], ) => { if (moves.length === 0) { return; } const positionByNodeId = new Map( moves.map((move) => [move.nodeId, { x: move.positionX, y: move.positionY }]), ); setNodes((currentNodes) => currentNodes.map((node) => { const nextPosition = positionByNodeId.get(node.id as Id<"nodes">); if (!nextPosition) { return node; } if (node.position.x === nextPosition.x && node.position.y === nextPosition.y) { return node; } return { ...node, position: nextPosition, }; }), ); }, [], ); const { edgeInsertMenu, edgeInsertTemplates, closeEdgeInsertMenu, openEdgeInsertMenu, handleEdgeInsertPick, } = useCanvasEdgeInsertions({ canvasId, nodes, edges, runCreateNodeWithEdgeSplitOnlineOnly, runBatchMoveNodesMutation, applyLocalNodeMoves: applyLocalEdgeInsertMoves, showConnectionRejectedToast, onReflowStateChange: setIsEdgeInsertReflowing, reflowSettleMs: EDGE_INSERT_REFLOW_SETTLE_MS, }); const handleEdgeInsertClick = useCallback( (anchor: DefaultEdgeInsertAnchor) => { closeConnectionDropMenu(); openEdgeInsertMenu(anchor); }, [closeConnectionDropMenu, openEdgeInsertMenu], ); useEffect(() => { if (connectionDropMenu) { closeEdgeInsertMenu(); } }, [closeEdgeInsertMenu, connectionDropMenu]); const defaultEdgeOptions = useMemo( () => ({ ...DEFAULT_EDGE_OPTIONS, type: "canvas-default" as const, }), [], ); const edgeInsertReflowStyle = useMemo( () => ({ "--ls-edge-insert-reflow-duration": `${EDGE_INSERT_REFLOW_SETTLE_MS}ms`, }) as CSSProperties, [], ); const edgeTypes = useCanvasEdgeTypes({ edgeInsertMenuEdgeId: edgeInsertMenu?.edgeId ?? null, scissorsMode, onInsertClick: handleEdgeInsertClick, }); useCanvasFlowReconciliation({ convexNodes, convexEdges, storageUrlsById, themeMode: resolvedTheme === "dark" ? "dark" : "light", pendingRemovedEdgeIds, pendingMovePins, setNodes, setEdges, refs: { nodesRef, edgesRef, deletingNodeIds, convexNodeIdsSnapshotForEdgeCarryRef, resolvedRealIdByClientRequestRef, pendingConnectionCreatesRef, pendingLocalPositionUntilConvexMatchesRef, pendingLocalNodeDataUntilConvexMatchesRef, pendingLocalNodeSizeUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, isDragging, isResizing, }, }); useEffect(() => { if (isDragging.current) return; let cancelled = false; queueMicrotask(() => { if (!cancelled) { setNodes((nds) => withResolvedCompareData(nds, edges)); } }); return () => { cancelled = true; }; }, [edges]); const { onNodesChange, onNodeDragStart, onNodeDrag, onNodeDragStop, } = useCanvasNodeInteractions({ canvasId, nodes, edges, setNodes, setEdges, refs: { isDragging, isResizing, pendingLocalPositionUntilConvexMatchesRef, preferLocalPositionNodeIdsRef, pendingMoveAfterCreateRef, resolvedRealIdByClientRequestRef, pendingEdgeSplitByClientRequestRef, }, runResizeNodeMutation, runMoveNodeMutation, runBatchMoveNodesMutation, runSplitEdgeAtExistingNodeMutation, onInvalidConnection: showConnectionRejectedToast, syncPendingMoveForClientRequest, }); 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 { onDragOver, onDrop } = useCanvasDrop({ canvasId, isSyncOnline, t, edges, screenToFlowPosition, generateUploadUrl, registerUploadedImageMedia, runCreateNodeOnlineOnly, runCreateNodeWithEdgeSplitOnlineOnly, 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, ); }, ); }} >
{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 ( ); }