From 59658cb8bef6bc515ff5ec1fd5cabbcdd4d96f82 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Fri, 3 Apr 2026 22:59:47 +0200 Subject: [PATCH] refactor(canvas): extract connection handling hook --- .../__tests__/use-canvas-connections.test.tsx | 322 ++++++++++++++++++ components/canvas/canvas.tsx | 223 ++---------- components/canvas/use-canvas-connections.ts | 301 ++++++++++++++++ vitest.config.ts | 1 + 4 files changed, 649 insertions(+), 198 deletions(-) create mode 100644 components/canvas/__tests__/use-canvas-connections.test.tsx create mode 100644 components/canvas/use-canvas-connections.ts diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx new file mode 100644 index 0000000..6a2a69c --- /dev/null +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -0,0 +1,322 @@ +// @vitest-environment jsdom + +import React, { act, useEffect, useRef } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { Connection, Edge as RFEdge, Node as RFNode } from "@xyflow/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Id } from "@/convex/_generated/dataModel"; +import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; +import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; +import { useCanvasConnections } from "@/components/canvas/use-canvas-connections"; + +const { + validateCanvasConnectionMock, + validateCanvasConnectionByTypeMock, + useCanvasReconnectHandlersMock, + getConnectEndClientPointMock, +} = vi.hoisted(() => ({ + validateCanvasConnectionMock: vi.fn(), + validateCanvasConnectionByTypeMock: vi.fn(), + useCanvasReconnectHandlersMock: vi.fn(), + getConnectEndClientPointMock: vi.fn(), +})); + +vi.mock("@/components/canvas/canvas-connection-validation", () => ({ + validateCanvasConnection: validateCanvasConnectionMock, + validateCanvasConnectionByType: validateCanvasConnectionByTypeMock, +})); + +vi.mock("@/components/canvas/canvas-reconnect", () => ({ + useCanvasReconnectHandlers: useCanvasReconnectHandlersMock, +})); + +vi.mock("@/components/canvas/canvas-helpers", async () => { + const actual = await vi.importActual< + typeof import("@/components/canvas/canvas-helpers") + >("@/components/canvas/canvas-helpers"); + + return { + ...actual, + getConnectEndClientPoint: getConnectEndClientPointMock, + }; +}); + +const asCanvasId = (id: string): Id<"canvases"> => id as Id<"canvases">; +const asNodeId = (id: string): Id<"nodes"> => id as Id<"nodes">; + +type HarnessProps = { + nodes: RFNode[]; + edges: RFEdge[]; + runCreateEdgeMutation?: ReturnType; + runRemoveEdgeMutation?: ReturnType; + runCreateNodeWithEdgeFromSourceOnlineOnly?: ReturnType; + runCreateNodeWithEdgeToTargetOnlineOnly?: ReturnType; + syncPendingMoveForClientRequest?: ReturnType; + showConnectionRejectedToast?: ReturnType; + isReconnectDragActive?: boolean; +}; + +const latestHookRef: { + current: ReturnType | null; +} = { current: null }; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function HookHarness(props: HarnessProps) { + const nodesRef = useRef(props.nodes); + const edgesRef = useRef(props.edges); + const pendingConnectionCreatesRef = useRef(new Set()); + const resolvedRealIdByClientRequestRef = useRef(new Map>()); + const edgeReconnectSuccessful = useRef(true); + const isReconnectDragActiveRef = useRef(Boolean(props.isReconnectDragActive)); + + useEffect(() => { + nodesRef.current = props.nodes; + edgesRef.current = props.edges; + }, [props.edges, props.nodes]); + + const hookValue = useCanvasConnections({ + canvasId: asCanvasId("canvas-1"), + nodes: props.nodes, + edges: props.edges, + nodesRef, + edgesRef, + edgeReconnectSuccessful, + isReconnectDragActiveRef, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, + setEdges: vi.fn(), + setEdgeSyncNonce: vi.fn(), + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ x: x - 10, y: y - 20 }), + syncPendingMoveForClientRequest: + props.syncPendingMoveForClientRequest ?? vi.fn(async () => undefined), + runCreateEdgeMutation: props.runCreateEdgeMutation ?? vi.fn(async () => undefined), + runRemoveEdgeMutation: props.runRemoveEdgeMutation ?? vi.fn(async () => undefined), + runCreateNodeWithEdgeFromSourceOnlineOnly: + props.runCreateNodeWithEdgeFromSourceOnlineOnly ?? vi.fn(async () => asNodeId("node-new")), + runCreateNodeWithEdgeToTargetOnlineOnly: + props.runCreateNodeWithEdgeToTargetOnlineOnly ?? vi.fn(async () => asNodeId("node-new")), + showConnectionRejectedToast: + props.showConnectionRejectedToast ?? vi.fn(), + }); + + useEffect(() => { + latestHookRef.current = hookValue; + }, [hookValue]); + + return null; +} + +describe("useCanvasConnections", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + validateCanvasConnectionMock.mockReturnValue(null); + validateCanvasConnectionByTypeMock.mockReturnValue(null); + getConnectEndClientPointMock.mockReturnValue({ x: 140, y: 220 }); + useCanvasReconnectHandlersMock.mockReturnValue({ + onReconnectStart: vi.fn(), + onReconnect: vi.fn(), + onReconnectEnd: vi.fn(), + }); + }); + + afterEach(async () => { + latestHookRef.current = null; + vi.clearAllMocks(); + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + root = null; + container = null; + }); + + async function renderHook(props: HarnessProps) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(); + }); + } + + it("creates a valid edge through centralized validation", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + await renderHook({ + nodes: [ + { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "node-text", type: "text", position: { x: 10, y: 10 }, data: {} }, + ], + edges: [], + runCreateEdgeMutation, + }); + + const connection: Connection = { + source: "node-image", + target: "node-text", + sourceHandle: null, + targetHandle: null, + }; + + await act(async () => { + latestHookRef.current?.onConnect(connection); + }); + + expect(validateCanvasConnectionMock).toHaveBeenCalledWith(connection, expect.any(Array), []); + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-image", + targetNodeId: "node-text", + sourceHandle: undefined, + targetHandle: undefined, + }); + }); + + it("rejects invalid connections without mutating edges", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + validateCanvasConnectionMock.mockReturnValue("self-loop"); + + await renderHook({ + nodes: [ + { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} }, + ], + edges: [], + runCreateEdgeMutation, + showConnectionRejectedToast, + }); + + await act(async () => { + latestHookRef.current?.onConnect({ + source: "node-image", + target: "node-image", + sourceHandle: null, + targetHandle: null, + }); + }); + + expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + }); + + it("opens the connection drop menu from an invalid connect end", async () => { + await renderHook({ + nodes: [ + { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} }, + ], + edges: [], + }); + + await act(async () => { + latestHookRef.current?.onConnectEnd({} as MouseEvent, { + isValid: false, + fromNode: { id: "node-image" }, + fromHandle: { id: "source", type: "source" }, + } as never); + }); + + expect(latestHookRef.current?.connectionDropMenu).toEqual({ + screenX: 140, + screenY: 220, + flowX: 130, + flowY: 200, + fromNodeId: "node-image", + fromHandleId: "source", + fromHandleType: "source", + }); + }); + + it("routes connection-drop creation through type validation and source creation", async () => { + const runCreateNodeWithEdgeFromSourceOnlineOnly = vi.fn(async () => asNodeId("node-new")); + const syncPendingMoveForClientRequest = vi.fn(async () => undefined); + + await renderHook({ + nodes: [ + { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} }, + ], + edges: [], + runCreateNodeWithEdgeFromSourceOnlineOnly, + syncPendingMoveForClientRequest, + }); + + await act(async () => { + latestHookRef.current?.onConnectEnd({} as MouseEvent, { + isValid: false, + fromNode: { id: "node-image" }, + fromHandle: { id: "source", type: "source" }, + } as never); + }); + + const template = { + ...CANVAS_NODE_TEMPLATES.find((entry) => entry.type === "text")!, + defaultData: { content: "Hello" }, + } as unknown as CanvasNodeTemplate; + + await act(async () => { + latestHookRef.current?.handleConnectionDropPick(template); + await Promise.resolve(); + }); + + expect(validateCanvasConnectionByTypeMock).toHaveBeenCalledWith({ + sourceType: "image", + targetType: "text", + targetNodeId: expect.stringMatching(/^__pending_text_/), + edges: [], + }); + expect(runCreateNodeWithEdgeFromSourceOnlineOnly).toHaveBeenCalledWith( + expect.objectContaining({ + canvasId: "canvas-1", + type: "text", + positionX: 130, + positionY: 200, + sourceNodeId: "node-image", + sourceHandle: "source", + data: expect.objectContaining({ + canvasId: "canvas-1", + content: "Hello", + }), + }), + ); + expect(syncPendingMoveForClientRequest).toHaveBeenCalled(); + }); + + it("adapts reconnect handlers through the shared connection validation", async () => { + const showConnectionRejectedToast = vi.fn(); + + await renderHook({ + nodes: [ + { id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "node-text", type: "text", position: { x: 10, y: 10 }, data: {} }, + ], + edges: [{ id: "edge-1", source: "node-image", target: "node-text" }], + showConnectionRejectedToast, + }); + + const reconnectArgs = useCanvasReconnectHandlersMock.mock.calls[0][0]; + const reconnectValidation = reconnectArgs.validateConnection( + { id: "edge-1", source: "node-image", target: "node-text" }, + { source: "node-image", target: "node-text" }, + ); + + reconnectArgs.onInvalidConnection("unknown-node"); + + expect(validateCanvasConnectionMock).toHaveBeenCalledWith( + { source: "node-image", target: "node-text" }, + expect.any(Array), + expect.any(Array), + "edge-1", + ); + expect(reconnectValidation).toBeNull(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("unknown-node"); + expect(latestHookRef.current?.onReconnect).toBe( + useCanvasReconnectHandlersMock.mock.results[0]?.value.onReconnect, + ); + }); +}); diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 7c62051..d874ff6 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -20,8 +20,6 @@ import { type Node as RFNode, type Edge as RFEdge, type EdgeChange, - type Connection, - type OnConnectEnd, BackgroundVariant, } from "@xyflow/react"; import { cn } from "@/lib/utils"; @@ -42,14 +40,7 @@ import { } from "@/lib/canvas-node-types"; import { nodeTypes } from "./node-types"; -import { - validateCanvasConnection, - validateCanvasConnectionByType, -} from "./canvas-connection-validation"; -import { - NODE_DEFAULTS, - NODE_HANDLE_MAP, -} from "@/lib/canvas-utils"; +import { NODE_DEFAULTS } from "@/lib/canvas-utils"; import CanvasToolbar, { type CanvasNavTool, } from "@/components/canvas/canvas-toolbar"; @@ -57,7 +48,6 @@ 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"; @@ -66,24 +56,21 @@ import { 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, DEFAULT_EDGE_OPTIONS, - getConnectEndClientPoint, getMiniMapNodeColor, getMiniMapNodeStrokeColor, getPendingRemovedEdgeIdsFromLocalOps, getPendingMovePinsFromLocalOps, isEditableKeyboardTarget, - isOptimisticNodeId, withResolvedCompareData, } from "./canvas-helpers"; import { useGenerationFailureWarnings } from "./canvas-generation-failures"; import { useCanvasDeleteHandlers } from "./canvas-delete-handlers"; import { getImageDimensions } from "./canvas-media-utils"; import { useCanvasNodeInteractions } from "./use-canvas-node-interactions"; -import { useCanvasReconnectHandlers } from "./canvas-reconnect"; +import { useCanvasConnections } from "./use-canvas-connections"; import { useCanvasScissors } from "./canvas-scissors"; import { CanvasSyncProvider } from "./canvas-sync-context"; import { useCanvasData } from "./use-canvas-data"; @@ -167,10 +154,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { // ─── 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< @@ -293,18 +276,34 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { runRemoveEdgeMutation, }); - const { onReconnectStart, onReconnect, onReconnectEnd } = useCanvasReconnectHandlers({ + const { + connectionDropMenu, + closeConnectionDropMenu, + handleConnectionDropPick, + onConnect, + onConnectEnd, + onReconnectStart, + onReconnect, + onReconnectEnd, + } = useCanvasConnections({ canvasId, + nodes, + edges, + nodesRef, + edgesRef, edgeReconnectSuccessful, isReconnectDragActiveRef, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, setEdges, + setEdgeSyncNonce, + screenToFlowPosition, + syncPendingMoveForClientRequest, runCreateEdgeMutation, runRemoveEdgeMutation, - validateConnection: (oldEdge, nextConnection) => - validateCanvasConnection(nextConnection, nodes, edges, oldEdge.id), - onInvalidConnection: (reason) => { - showConnectionRejectedToast(reason as CanvasConnectionValidationReason); - }, + runCreateNodeWithEdgeFromSourceOnlineOnly, + runCreateNodeWithEdgeToTargetOnlineOnly, + showConnectionRejectedToast, }); useCanvasFlowReconciliation({ @@ -372,178 +371,6 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { console.error("[ReactFlow error]", { canvasId, id, error }); }, [canvasId]); - // ─── 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(); @@ -753,7 +580,7 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { setConnectionDropMenu(null)} + onClose={closeConnectionDropMenu} onPick={handleConnectionDropPick} /> {scissorsMode ? ( diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts new file mode 100644 index 0000000..07034a9 --- /dev/null +++ b/components/canvas/use-canvas-connections.ts @@ -0,0 +1,301 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import type { Connection, Edge as RFEdge, Node as RFNode, OnConnectEnd } from "@xyflow/react"; + +import type { Id } from "@/convex/_generated/dataModel"; +import { + NODE_DEFAULTS, + NODE_HANDLE_MAP, +} from "@/lib/canvas-utils"; +import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-policy"; +import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; + +import { getConnectEndClientPoint, isOptimisticNodeId } from "./canvas-helpers"; +import { + validateCanvasConnection, + validateCanvasConnectionByType, +} from "./canvas-connection-validation"; +import { useCanvasReconnectHandlers } from "./canvas-reconnect"; +import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu"; + +type UseCanvasConnectionsParams = { + canvasId: Id<"canvases">; + nodes: RFNode[]; + edges: RFEdge[]; + nodesRef: MutableRefObject; + edgesRef: MutableRefObject; + edgeReconnectSuccessful: MutableRefObject; + isReconnectDragActiveRef: MutableRefObject; + pendingConnectionCreatesRef: MutableRefObject>; + resolvedRealIdByClientRequestRef: MutableRefObject>>; + setEdges: Dispatch>; + setEdgeSyncNonce: Dispatch>; + screenToFlowPosition: (position: { x: number; y: number }) => { x: number; y: number }; + syncPendingMoveForClientRequest: ( + clientRequestId: string, + realId?: Id<"nodes">, + ) => Promise; + runCreateEdgeMutation: (args: { + canvasId: Id<"canvases">; + sourceNodeId: Id<"nodes">; + targetNodeId: Id<"nodes">; + sourceHandle?: string; + targetHandle?: string; + }) => Promise; + runRemoveEdgeMutation: (args: { edgeId: Id<"edges"> }) => Promise; + runCreateNodeWithEdgeFromSourceOnlineOnly: (args: { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: Record; + clientRequestId?: string; + sourceNodeId: string; + parentId?: string; + zIndex?: number; + sourceHandle?: string; + targetHandle?: string; + }) => Promise | string>; + runCreateNodeWithEdgeToTargetOnlineOnly: (args: { + canvasId: Id<"canvases">; + type: string; + positionX: number; + positionY: number; + width: number; + height: number; + data: Record; + clientRequestId?: string; + targetNodeId: string; + parentId?: string; + zIndex?: number; + sourceHandle?: string; + targetHandle?: string; + }) => Promise | string>; + showConnectionRejectedToast: (reason: CanvasConnectionValidationReason) => void; +}; + +export function useCanvasConnections({ + canvasId, + nodes, + edges, + nodesRef, + edgesRef, + edgeReconnectSuccessful, + isReconnectDragActiveRef, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, + setEdges, + setEdgeSyncNonce, + screenToFlowPosition, + syncPendingMoveForClientRequest, + runCreateEdgeMutation, + runRemoveEdgeMutation, + runCreateNodeWithEdgeFromSourceOnlineOnly, + runCreateNodeWithEdgeToTargetOnlineOnly, + showConnectionRejectedToast, +}: UseCanvasConnectionsParams) { + const [connectionDropMenu, setConnectionDropMenu] = + useState(null); + const connectionDropMenuRef = useRef(null); + const closeConnectionDropMenu = useCallback(() => setConnectionDropMenu(null), []); + + useEffect(() => { + connectionDropMenuRef.current = connectionDropMenu; + }, [connectionDropMenu]); + + 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, + }); + }, + [isReconnectDragActiveRef, 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; + } + const settledRealId = realId as Id<"nodes">; + resolvedRealIdByClientRequestRef.current.set(clientRequestId, settledRealId); + settle(settledRealId); + 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; + } + const settledRealId = realId as Id<"nodes">; + resolvedRealIdByClientRequestRef.current.set(clientRequestId, settledRealId); + settle(settledRealId); + setEdgeSyncNonce((n) => n + 1); + }) + .catch((error) => { + pendingConnectionCreatesRef.current.delete(clientRequestId); + console.error("[Canvas] createNodeWithEdgeToTarget failed", error); + }); + } + }, + [ + canvasId, + edgesRef, + nodesRef, + pendingConnectionCreatesRef, + resolvedRealIdByClientRequestRef, + runCreateNodeWithEdgeFromSourceOnlineOnly, + runCreateNodeWithEdgeToTargetOnlineOnly, + setEdgeSyncNonce, + showConnectionRejectedToast, + syncPendingMoveForClientRequest, + ], + ); + + 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); + }, + }); + + return { + connectionDropMenu, + closeConnectionDropMenu, + handleConnectionDropPick, + onConnect, + onConnectEnd, + onReconnectStart, + onReconnect, + onReconnectEnd, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 3600b04..62aaffa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ include: [ "tests/**/*.test.ts", "components/canvas/__tests__/canvas-flow-reconciliation-helpers.test.ts", + "components/canvas/__tests__/use-canvas-connections.test.tsx", "components/canvas/__tests__/use-canvas-flow-reconciliation.test.ts", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-sync-engine.test.ts",