From 1d691999ddf76de0c1ec6133e865e1dff0394303 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 08:41:14 +0200 Subject: [PATCH] feat(canvas): share magnet state across connection drags --- .../__tests__/use-canvas-connections.test.tsx | 304 ++++++++++- .../canvas-connection-magnetism-context.tsx | 51 ++ components/canvas/canvas-reconnect.ts | 7 +- components/canvas/canvas.tsx | 5 +- components/canvas/use-canvas-connections.ts | 479 ++++++++++-------- 5 files changed, 635 insertions(+), 211 deletions(-) create mode 100644 components/canvas/canvas-connection-magnetism-context.tsx diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx index 2d67fad..60f9720 100644 --- a/components/canvas/__tests__/use-canvas-connections.test.tsx +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Id } from "@/convex/_generated/dataModel"; +import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism"; const mocks = vi.hoisted(() => ({ resolveDroppedConnectionTarget: vi.fn(), + resolveCanvasMagnetTarget: vi.fn(), })); vi.mock("@/components/canvas/canvas-helpers", async () => { @@ -22,8 +24,23 @@ vi.mock("@/components/canvas/canvas-helpers", async () => { }; }); +vi.mock("@/components/canvas/canvas-connection-magnetism", async () => { + const actual = await vi.importActual< + typeof import("@/components/canvas/canvas-connection-magnetism") + >("@/components/canvas/canvas-connection-magnetism"); + + return { + ...actual, + resolveCanvasMagnetTarget: mocks.resolveCanvasMagnetTarget, + }; +}); + import { useCanvasConnections } from "@/components/canvas/use-canvas-connections"; import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; +import { + CanvasConnectionMagnetismProvider, + useCanvasConnectionMagnetism, +} from "@/components/canvas/canvas-connection-magnetism-context"; import { nodeTypes } from "@/components/canvas/node-types"; import { NODE_CATALOG } from "@/lib/canvas-node-catalog"; import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; @@ -35,6 +52,14 @@ const latestHandlersRef: { current: ReturnType | null; } = { current: null }; +const latestMagnetTargetRef: { + current: CanvasMagnetTarget | null; +} = { current: null }; + +const latestSetActiveTargetRef: { + current: ((target: CanvasMagnetTarget | null) => void) | null; +} = { current: null }; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type HookHarnessProps = { @@ -47,9 +72,12 @@ type HookHarnessProps = { setEdgesMock?: ReturnType; nodes?: RFNode[]; edges?: RFEdge[]; + initialMagnetTarget?: CanvasMagnetTarget | null; }; -function HookHarness({ +type HookHarnessInnerProps = HookHarnessProps; + +function HookHarnessInner({ helperResult, runCreateEdgeMutation = vi.fn(async () => undefined), runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), @@ -59,7 +87,10 @@ function HookHarness({ setEdgesMock, nodes: providedNodes, edges: providedEdges, -}: HookHarnessProps) { + initialMagnetTarget, +}: HookHarnessInnerProps) { + const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); + const didInitializeMagnetTargetRef = useRef(false); const [nodes] = useState( providedNodes ?? [ { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, @@ -88,6 +119,17 @@ function HookHarness({ mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult); }, [helperResult]); + useEffect(() => { + mocks.resolveCanvasMagnetTarget.mockReturnValue(null); + }, []); + + useEffect(() => { + if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) { + didInitializeMagnetTargetRef.current = true; + setActiveTarget(initialMagnetTarget); + } + }, [initialMagnetTarget, setActiveTarget]); + const handlers = useCanvasConnections({ canvasId: asCanvasId("canvas-1"), nodes, @@ -115,15 +157,36 @@ function HookHarness({ latestHandlersRef.current = handlers; }, [handlers]); + useEffect(() => { + latestMagnetTargetRef.current = activeTarget; + }, [activeTarget]); + + useEffect(() => { + latestSetActiveTargetRef.current = setActiveTarget; + return () => { + latestSetActiveTargetRef.current = null; + }; + }, [setActiveTarget]); + return null; } +function HookHarness(props: HookHarnessProps) { + return ( + + + + ); +} + describe("useCanvasConnections", () => { let container: HTMLDivElement | null = null; let root: Root | null = null; afterEach(async () => { latestHandlersRef.current = null; + latestMagnetTargetRef.current = null; + latestSetActiveTargetRef.current = null; vi.clearAllMocks(); if (root) { await act(async () => { @@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => { expect(runSwapMixerInputsMutation).not.toHaveBeenCalled(); expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); }); + + it("falls back to active magnet target when direct drop resolution misses", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-source", + targetNodeId: "node-target", + sourceHandle: undefined, + targetHandle: "base", + }); + expect(latestMagnetTargetRef.current).toBeNull(); + }); + + it("rejects invalid active magnet target and clears transient state", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 120, clientY: 120 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 120, y: 120 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); + expect(latestMagnetTargetRef.current).toBeNull(); + }); + + it("clears transient magnet state when dropping on background opens menu", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectEnd( + { clientX: 500, clientY: 460 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 500, y: 460 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(latestHandlersRef.current?.connectionDropMenu).toEqual( + expect.objectContaining({ + screenX: 500, + screenY: 460, + }), + ); + expect(latestMagnetTargetRef.current).toBeNull(); + }); + + it("clears transient magnet state when reconnect drag ends", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-1", + source: "node-source", + target: "node-target", + targetHandle: "base", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source", + target: "node-target", + sourceHandle: null, + targetHandle: "overlay", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalled(); + expect(latestMagnetTargetRef.current).toBeNull(); + }); }); diff --git a/components/canvas/canvas-connection-magnetism-context.tsx b/components/canvas/canvas-connection-magnetism-context.tsx new file mode 100644 index 0000000..e0b85aa --- /dev/null +++ b/components/canvas/canvas-connection-magnetism-context.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism"; + +type CanvasConnectionMagnetismState = { + activeTarget: CanvasMagnetTarget | null; + setActiveTarget: (target: CanvasMagnetTarget | null) => void; +}; + +const CanvasConnectionMagnetismContext = + createContext(null); + +export function CanvasConnectionMagnetismProvider({ + children, +}: { + children: ReactNode; +}) { + const [activeTarget, setActiveTarget] = useState(null); + + const value = useMemo( + () => ({ + activeTarget, + setActiveTarget, + }), + [activeTarget], + ); + + return ( + + {children} + + ); +} + +export function useCanvasConnectionMagnetism(): CanvasConnectionMagnetismState { + const context = useContext(CanvasConnectionMagnetismContext); + if (!context) { + throw new Error( + "useCanvasConnectionMagnetism must be used within CanvasConnectionMagnetismProvider", + ); + } + return context; +} diff --git a/components/canvas/canvas-reconnect.ts b/components/canvas/canvas-reconnect.ts index 000ec0b..52aaadb 100644 --- a/components/canvas/canvas-reconnect.ts +++ b/components/canvas/canvas-reconnect.ts @@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = { nextOtherEdgeHandle: "base" | "overlay"; } | null; onInvalidConnection?: (message: string) => void; + clearActiveMagnetTarget?: () => void; }; export function useCanvasReconnectHandlers({ @@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({ validateConnection, resolveMixerSwapReconnect, onInvalidConnection, + clearActiveMagnetTarget, }: UseCanvasReconnectHandlersParams): { onReconnectStart: () => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; @@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({ >(null); const onReconnectStart = useCallback(() => { + clearActiveMagnetTarget?.(); edgeReconnectSuccessful.current = false; isReconnectDragActiveRef.current = true; pendingReconnectRef.current = null; - }, [edgeReconnectSuccessful, isReconnectDragActiveRef]); + }, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]); const onReconnect = useCallback( (oldEdge: RFEdge, newConnection: Connection) => { @@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({ edgeReconnectSuccessful.current = true; } finally { + clearActiveMagnetTarget?.(); isReconnectDragActiveRef.current = false; } }, [ canvasId, + clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef, runCreateEdgeMutation, diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 0eb8ae1..13ec262 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -78,6 +78,7 @@ 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"; +import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context"; interface CanvasInnerProps { canvasId: Id<"canvases">; @@ -709,7 +710,9 @@ interface CanvasProps { export default function Canvas({ canvasId }: CanvasProps) { return ( - + + + ); } diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index e3ea26c..3a98b31 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeType } from "@/lib/canvas-node-types"; +import { + resolveCanvasMagnetTarget, + type CanvasMagnetTarget, +} from "./canvas-connection-magnetism"; import { getConnectEndClientPoint, hasHandleKey, @@ -24,6 +28,7 @@ import { validateCanvasConnectionByType, validateCanvasEdgeSplit, } from "./canvas-connection-validation"; +import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context"; import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu"; @@ -122,6 +127,7 @@ export function useCanvasConnections({ runSwapMixerInputsMutation, showConnectionRejectedToast, }: UseCanvasConnectionsParams) { + const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const [connectionDropMenu, setConnectionDropMenu] = useState(null); const connectionDropMenuRef = useRef(null); @@ -133,56 +139,82 @@ export function useCanvasConnections({ }, [connectionDropMenu]); const onConnectStart = useCallback((_event, params) => { + setActiveTarget(null); isConnectDragActiveRef.current = true; logCanvasConnectionDebug("connect:start", { nodeId: params.nodeId, handleId: params.handleId, handleType: params.handleType, }); - }, []); + }, [setActiveTarget]); + + const toDroppedConnectionFromMagnetTarget = useCallback( + (fromHandleType: "source" | "target", fromNodeId: string, fromHandleId: string | undefined, magnetTarget: CanvasMagnetTarget) => { + if (fromHandleType === "source") { + return { + sourceNodeId: fromNodeId, + targetNodeId: magnetTarget.nodeId, + sourceHandle: fromHandleId, + targetHandle: magnetTarget.handleId, + }; + } + + return { + sourceNodeId: magnetTarget.nodeId, + targetNodeId: fromNodeId, + sourceHandle: magnetTarget.handleId, + targetHandle: fromHandleId, + }; + }, + [], + ); const onConnect = useCallback( (connection: Connection) => { isConnectDragActiveRef.current = false; - const validationError = validateCanvasConnection(connection, nodes, edges); - if (validationError) { - logCanvasConnectionDebug("connect:invalid-direct", { - sourceNodeId: connection.source ?? null, - targetNodeId: connection.target ?? null, - sourceHandle: connection.sourceHandle ?? null, - targetHandle: connection.targetHandle ?? null, - validationError, - }); - showConnectionRejectedToast(validationError); - return; - } + try { + const validationError = validateCanvasConnection(connection, nodes, edges); + if (validationError) { + logCanvasConnectionDebug("connect:invalid-direct", { + sourceNodeId: connection.source ?? null, + targetNodeId: connection.target ?? null, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + validationError, + }); + showConnectionRejectedToast(validationError); + return; + } - if (!connection.source || !connection.target) { - logCanvasConnectionDebug("connect:missing-endpoint", { - sourceNodeId: connection.source ?? null, - targetNodeId: connection.target ?? null, + if (!connection.source || !connection.target) { + logCanvasConnectionDebug("connect:missing-endpoint", { + sourceNodeId: connection.source ?? null, + targetNodeId: connection.target ?? null, + sourceHandle: connection.sourceHandle ?? null, + targetHandle: connection.targetHandle ?? null, + }); + return; + } + + logCanvasConnectionDebug("connect:direct", { + sourceNodeId: connection.source, + targetNodeId: connection.target, sourceHandle: connection.sourceHandle ?? null, targetHandle: connection.targetHandle ?? null, }); - return; + + void runCreateEdgeMutation({ + canvasId, + sourceNodeId: connection.source as Id<"nodes">, + targetNodeId: connection.target as Id<"nodes">, + sourceHandle: connection.sourceHandle ?? undefined, + targetHandle: connection.targetHandle ?? undefined, + }); + } finally { + setActiveTarget(null); } - - logCanvasConnectionDebug("connect:direct", { - sourceNodeId: connection.source, - targetNodeId: connection.target, - sourceHandle: connection.sourceHandle ?? null, - targetHandle: connection.targetHandle ?? null, - }); - - 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], + [canvasId, edges, nodes, runCreateEdgeMutation, setActiveTarget, showConnectionRejectedToast], ); const resolveMixerSwapReconnect = useCallback( @@ -252,6 +284,7 @@ export function useCanvasConnections({ const onConnectEnd = useCallback( (event, connectionState) => { if (!isConnectDragActiveRef.current) { + setActiveTarget(null); logCanvasConnectionDebug("connect:end-ignored", { reason: "drag-not-active", isValid: connectionState.isValid ?? null, @@ -264,187 +297,213 @@ export function useCanvasConnections({ } isConnectDragActiveRef.current = false; - if (isReconnectDragActiveRef.current) { - logCanvasConnectionDebug("connect:end-ignored", { - reason: "reconnect-active", - isValid: connectionState.isValid ?? null, - fromNodeId: connectionState.fromNode?.id ?? null, - fromHandleId: connectionState.fromHandle?.id ?? null, - toNodeId: connectionState.toNode?.id ?? null, - toHandleId: connectionState.toHandle?.id ?? null, - }); - return; - } - if (connectionState.isValid === true) { - logCanvasConnectionDebug("connect:end-ignored", { - reason: "react-flow-valid-connection", - fromNodeId: connectionState.fromNode?.id ?? null, - fromHandleId: connectionState.fromHandle?.id ?? null, - toNodeId: connectionState.toNode?.id ?? null, - toHandleId: connectionState.toHandle?.id ?? null, - }); - return; - } - const fromNode = connectionState.fromNode; - const fromHandle = connectionState.fromHandle; - if (!fromNode || !fromHandle) { - logCanvasConnectionDebug("connect:end-aborted", { - reason: "missing-from-node-or-handle", - fromNodeId: fromNode?.id ?? null, - fromHandleId: fromHandle?.id ?? null, - toNodeId: connectionState.toNode?.id ?? null, - toHandleId: connectionState.toHandle?.id ?? null, - }); - return; - } + try { + if (isReconnectDragActiveRef.current) { + logCanvasConnectionDebug("connect:end-ignored", { + reason: "reconnect-active", + isValid: connectionState.isValid ?? null, + fromNodeId: connectionState.fromNode?.id ?? null, + fromHandleId: connectionState.fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + return; + } + if (connectionState.isValid === true) { + logCanvasConnectionDebug("connect:end-ignored", { + reason: "react-flow-valid-connection", + fromNodeId: connectionState.fromNode?.id ?? null, + fromHandleId: connectionState.fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + return; + } + const fromNode = connectionState.fromNode; + const fromHandle = connectionState.fromHandle; + if (!fromNode || !fromHandle) { + logCanvasConnectionDebug("connect:end-aborted", { + reason: "missing-from-node-or-handle", + fromNodeId: fromNode?.id ?? null, + fromHandleId: fromHandle?.id ?? null, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + return; + } - const pt = getConnectEndClientPoint(event); - if (!pt) { - logCanvasConnectionDebug("connect:end-aborted", { - reason: "missing-client-point", + const pt = getConnectEndClientPoint(event); + if (!pt) { + logCanvasConnectionDebug("connect:end-aborted", { + reason: "missing-client-point", + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + }); + return; + } + + logCanvasConnectionDebug("connect:end", { + point: pt, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + toNodeId: connectionState.toNode?.id ?? null, + toHandleId: connectionState.toHandle?.id ?? null, + }); + + const flow = screenToFlowPosition({ x: pt.x, y: pt.y }); + let droppedConnection = resolveDroppedConnectionTarget({ + point: pt, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? undefined, + fromHandleType: fromHandle.type, + nodes: nodesRef.current, + edges: edgesRef.current, + }); + + if (!droppedConnection) { + const fallbackMagnetTarget = + activeTarget ?? + resolveCanvasMagnetTarget({ + point: pt, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? undefined, + fromHandleType: fromHandle.type, + nodes: nodesRef.current, + edges: edgesRef.current, + }); + + if (fallbackMagnetTarget) { + droppedConnection = toDroppedConnectionFromMagnetTarget( + fromHandle.type, + fromNode.id, + fromHandle.id ?? undefined, + fallbackMagnetTarget, + ); + } + } + + logCanvasConnectionDebug("connect:end-drop-result", { + point: pt, + flow, + fromNodeId: fromNode.id, + fromHandleId: fromHandle.id ?? null, + fromHandleType: fromHandle.type, + droppedConnection, + }); + + if (droppedConnection) { + const validationError = validateCanvasConnection( + { + source: droppedConnection.sourceNodeId, + target: droppedConnection.targetNodeId, + sourceHandle: droppedConnection.sourceHandle ?? null, + targetHandle: droppedConnection.targetHandle ?? null, + }, + nodesRef.current, + edgesRef.current, + ); + if (validationError) { + const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id); + const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""]; + const incomingEdges = edgesRef.current.filter( + (edge) => + edge.target === droppedConnection.targetNodeId && + edge.className !== "temp" && + !isOptimisticEdgeId(edge.id), + ); + const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined; + const splitValidationError = + validationError === "adjustment-incoming-limit" && + droppedConnection.sourceNodeId === fromNode.id && + fromHandle.type === "source" && + fullFromNode !== undefined && + splitHandles !== undefined && + hasHandleKey(splitHandles, "source") && + hasHandleKey(splitHandles, "target") && + incomingEdge !== undefined && + incomingEdge.source !== fullFromNode.id && + incomingEdge.target !== fullFromNode.id + ? validateCanvasEdgeSplit({ + nodes: nodesRef.current, + edges: edgesRef.current, + splitEdge: incomingEdge, + middleNode: fullFromNode, + }) + : null; + + if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) { + logCanvasConnectionDebug("connect:end-auto-split", { + point: pt, + flow, + droppedConnection, + splitEdgeId: incomingEdge.id, + middleNodeId: fullFromNode.id, + }); + void runSplitEdgeAtExistingNodeMutation({ + canvasId, + splitEdgeId: incomingEdge.id as Id<"edges">, + middleNodeId: fullFromNode.id as Id<"nodes">, + splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle), + splitTargetHandle: normalizeHandle(incomingEdge.targetHandle), + newNodeSourceHandle: normalizeHandle(splitHandles.source), + newNodeTargetHandle: normalizeHandle(splitHandles.target), + }); + return; + } + + logCanvasConnectionDebug("connect:end-drop-rejected", { + point: pt, + flow, + droppedConnection, + validationError, + attemptedAutoSplit: + validationError === "adjustment-incoming-limit" && + droppedConnection.sourceNodeId === fromNode.id && + fromHandle.type === "source", + splitValidationError, + }); + showConnectionRejectedToast(validationError); + return; + } + + logCanvasConnectionDebug("connect:end-create-edge", { + point: pt, + flow, + droppedConnection, + }); + + void runCreateEdgeMutation({ + canvasId, + sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">, + targetNodeId: droppedConnection.targetNodeId as Id<"nodes">, + sourceHandle: droppedConnection.sourceHandle, + targetHandle: droppedConnection.targetHandle, + }); + return; + } + + logCanvasConnectionDebug("connect:end-open-menu", { + point: pt, + flow, fromNodeId: fromNode.id, fromHandleId: fromHandle.id ?? null, fromHandleType: fromHandle.type, }); - return; - } - logCanvasConnectionDebug("connect:end", { - point: pt, - fromNodeId: fromNode.id, - fromHandleId: fromHandle.id ?? null, - fromHandleType: fromHandle.type, - toNodeId: connectionState.toNode?.id ?? null, - toHandleId: connectionState.toHandle?.id ?? null, - }); - - const flow = screenToFlowPosition({ x: pt.x, y: pt.y }); - const droppedConnection = resolveDroppedConnectionTarget({ - point: pt, - fromNodeId: fromNode.id, - fromHandleId: fromHandle.id ?? undefined, - fromHandleType: fromHandle.type, - nodes: nodesRef.current, - edges: edgesRef.current, - }); - - logCanvasConnectionDebug("connect:end-drop-result", { - point: pt, - flow, - fromNodeId: fromNode.id, - fromHandleId: fromHandle.id ?? null, - fromHandleType: fromHandle.type, - droppedConnection, - }); - - if (droppedConnection) { - const validationError = validateCanvasConnection( - { - source: droppedConnection.sourceNodeId, - target: droppedConnection.targetNodeId, - sourceHandle: droppedConnection.sourceHandle ?? null, - targetHandle: droppedConnection.targetHandle ?? null, - }, - nodesRef.current, - edgesRef.current, - ); - if (validationError) { - const fullFromNode = nodesRef.current.find((node) => node.id === fromNode.id); - const splitHandles = NODE_HANDLE_MAP[fullFromNode?.type ?? ""]; - const incomingEdges = edgesRef.current.filter( - (edge) => - edge.target === droppedConnection.targetNodeId && - edge.className !== "temp" && - !isOptimisticEdgeId(edge.id), - ); - const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined; - const splitValidationError = - validationError === "adjustment-incoming-limit" && - droppedConnection.sourceNodeId === fromNode.id && - fromHandle.type === "source" && - fullFromNode !== undefined && - splitHandles !== undefined && - hasHandleKey(splitHandles, "source") && - hasHandleKey(splitHandles, "target") && - incomingEdge !== undefined && - incomingEdge.source !== fullFromNode.id && - incomingEdge.target !== fullFromNode.id - ? validateCanvasEdgeSplit({ - nodes: nodesRef.current, - edges: edgesRef.current, - splitEdge: incomingEdge, - middleNode: fullFromNode, - }) - : null; - - if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) { - logCanvasConnectionDebug("connect:end-auto-split", { - point: pt, - flow, - droppedConnection, - splitEdgeId: incomingEdge.id, - middleNodeId: fullFromNode.id, - }); - void runSplitEdgeAtExistingNodeMutation({ - canvasId, - splitEdgeId: incomingEdge.id as Id<"edges">, - middleNodeId: fullFromNode.id as Id<"nodes">, - splitSourceHandle: normalizeHandle(incomingEdge.sourceHandle), - splitTargetHandle: normalizeHandle(incomingEdge.targetHandle), - newNodeSourceHandle: normalizeHandle(splitHandles.source), - newNodeTargetHandle: normalizeHandle(splitHandles.target), - }); - return; - } - - logCanvasConnectionDebug("connect:end-drop-rejected", { - point: pt, - flow, - droppedConnection, - validationError, - attemptedAutoSplit: - validationError === "adjustment-incoming-limit" && - droppedConnection.sourceNodeId === fromNode.id && - fromHandle.type === "source", - splitValidationError, - }); - showConnectionRejectedToast(validationError); - return; - } - - logCanvasConnectionDebug("connect:end-create-edge", { - point: pt, - flow, - droppedConnection, + 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, }); - - void runCreateEdgeMutation({ - canvasId, - sourceNodeId: droppedConnection.sourceNodeId as Id<"nodes">, - targetNodeId: droppedConnection.targetNodeId as Id<"nodes">, - sourceHandle: droppedConnection.sourceHandle, - targetHandle: droppedConnection.targetHandle, - }); - return; + } finally { + setActiveTarget(null); } - - logCanvasConnectionDebug("connect:end-open-menu", { - point: pt, - flow, - fromNodeId: fromNode.id, - fromHandleId: fromHandle.id ?? null, - fromHandleType: fromHandle.type, - }); - - 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, - }); }, [ canvasId, @@ -454,7 +513,10 @@ export function useCanvasConnections({ runCreateEdgeMutation, runSplitEdgeAtExistingNodeMutation, screenToFlowPosition, + setActiveTarget, showConnectionRejectedToast, + activeTarget, + toDroppedConnectionFromMagnetTarget, ], ); @@ -598,6 +660,9 @@ export function useCanvasConnections({ onInvalidConnection: (reason) => { showConnectionRejectedToast(reason as CanvasConnectionValidationReason); }, + clearActiveMagnetTarget: () => { + setActiveTarget(null); + }, }); return {