feat(canvas): share magnet state across connection drags

This commit is contained in:
2026-04-11 08:41:14 +02:00
parent 52d5d487b8
commit 1d691999dd
5 changed files with 635 additions and 211 deletions

View File

@@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Id } from "@/convex/_generated/dataModel"; import type { Id } from "@/convex/_generated/dataModel";
import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
resolveDroppedConnectionTarget: vi.fn(), resolveDroppedConnectionTarget: vi.fn(),
resolveCanvasMagnetTarget: vi.fn(),
})); }));
vi.mock("@/components/canvas/canvas-helpers", async () => { 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 { useCanvasConnections } from "@/components/canvas/use-canvas-connections";
import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; 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 { nodeTypes } from "@/components/canvas/node-types";
import { NODE_CATALOG } from "@/lib/canvas-node-catalog"; import { NODE_CATALOG } from "@/lib/canvas-node-catalog";
import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates";
@@ -35,6 +52,14 @@ const latestHandlersRef: {
current: ReturnType<typeof useCanvasConnections> | null; current: ReturnType<typeof useCanvasConnections> | null;
} = { current: 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; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type HookHarnessProps = { type HookHarnessProps = {
@@ -47,9 +72,12 @@ type HookHarnessProps = {
setEdgesMock?: ReturnType<typeof vi.fn>; setEdgesMock?: ReturnType<typeof vi.fn>;
nodes?: RFNode[]; nodes?: RFNode[];
edges?: RFEdge[]; edges?: RFEdge[];
initialMagnetTarget?: CanvasMagnetTarget | null;
}; };
function HookHarness({ type HookHarnessInnerProps = HookHarnessProps;
function HookHarnessInner({
helperResult, helperResult,
runCreateEdgeMutation = vi.fn(async () => undefined), runCreateEdgeMutation = vi.fn(async () => undefined),
runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined),
@@ -59,7 +87,10 @@ function HookHarness({
setEdgesMock, setEdgesMock,
nodes: providedNodes, nodes: providedNodes,
edges: providedEdges, edges: providedEdges,
}: HookHarnessProps) { initialMagnetTarget,
}: HookHarnessInnerProps) {
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const didInitializeMagnetTargetRef = useRef(false);
const [nodes] = useState<RFNode[]>( const [nodes] = useState<RFNode[]>(
providedNodes ?? [ providedNodes ?? [
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
@@ -88,6 +119,17 @@ function HookHarness({
mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult); mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult);
}, [helperResult]); }, [helperResult]);
useEffect(() => {
mocks.resolveCanvasMagnetTarget.mockReturnValue(null);
}, []);
useEffect(() => {
if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) {
didInitializeMagnetTargetRef.current = true;
setActiveTarget(initialMagnetTarget);
}
}, [initialMagnetTarget, setActiveTarget]);
const handlers = useCanvasConnections({ const handlers = useCanvasConnections({
canvasId: asCanvasId("canvas-1"), canvasId: asCanvasId("canvas-1"),
nodes, nodes,
@@ -115,15 +157,36 @@ function HookHarness({
latestHandlersRef.current = handlers; latestHandlersRef.current = handlers;
}, [handlers]); }, [handlers]);
useEffect(() => {
latestMagnetTargetRef.current = activeTarget;
}, [activeTarget]);
useEffect(() => {
latestSetActiveTargetRef.current = setActiveTarget;
return () => {
latestSetActiveTargetRef.current = null;
};
}, [setActiveTarget]);
return null; return null;
} }
function HookHarness(props: HookHarnessProps) {
return (
<CanvasConnectionMagnetismProvider>
<HookHarnessInner {...props} />
</CanvasConnectionMagnetismProvider>
);
}
describe("useCanvasConnections", () => { describe("useCanvasConnections", () => {
let container: HTMLDivElement | null = null; let container: HTMLDivElement | null = null;
let root: Root | null = null; let root: Root | null = null;
afterEach(async () => { afterEach(async () => {
latestHandlersRef.current = null; latestHandlersRef.current = null;
latestMagnetTargetRef.current = null;
latestSetActiveTargetRef.current = null;
vi.clearAllMocks(); vi.clearAllMocks();
if (root) { if (root) {
await act(async () => { await act(async () => {
@@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => {
expect(runSwapMixerInputsMutation).not.toHaveBeenCalled(); expect(runSwapMixerInputsMutation).not.toHaveBeenCalled();
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); 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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
initialMagnetTarget={{
nodeId: "node-target",
handleId: "base",
handleType: "target",
centerX: 320,
centerY: 180,
distancePx: 12,
}}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
initialMagnetTarget={{
nodeId: "node-source",
handleType: "target",
centerX: 100,
centerY: 100,
distancePx: 10,
}}
/>,
);
});
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(
<HookHarness
helperResult={null}
initialMagnetTarget={{
nodeId: "node-target",
handleType: "target",
centerX: 200,
centerY: 220,
distancePx: 14,
}}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
initialMagnetTarget={{
nodeId: "node-target",
handleType: "target",
handleId: "overlay",
centerX: 300,
centerY: 180,
distancePx: 11,
}}
/>,
);
});
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();
});
}); });

View File

@@ -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<CanvasConnectionMagnetismState | null>(null);
export function CanvasConnectionMagnetismProvider({
children,
}: {
children: ReactNode;
}) {
const [activeTarget, setActiveTarget] = useState<CanvasMagnetTarget | null>(null);
const value = useMemo(
() => ({
activeTarget,
setActiveTarget,
}),
[activeTarget],
);
return (
<CanvasConnectionMagnetismContext.Provider value={value}>
{children}
</CanvasConnectionMagnetismContext.Provider>
);
}
export function useCanvasConnectionMagnetism(): CanvasConnectionMagnetismState {
const context = useContext(CanvasConnectionMagnetismContext);
if (!context) {
throw new Error(
"useCanvasConnectionMagnetism must be used within CanvasConnectionMagnetismProvider",
);
}
return context;
}

View File

@@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = {
nextOtherEdgeHandle: "base" | "overlay"; nextOtherEdgeHandle: "base" | "overlay";
} | null; } | null;
onInvalidConnection?: (message: string) => void; onInvalidConnection?: (message: string) => void;
clearActiveMagnetTarget?: () => void;
}; };
export function useCanvasReconnectHandlers({ export function useCanvasReconnectHandlers({
@@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({
validateConnection, validateConnection,
resolveMixerSwapReconnect, resolveMixerSwapReconnect,
onInvalidConnection, onInvalidConnection,
clearActiveMagnetTarget,
}: UseCanvasReconnectHandlersParams): { }: UseCanvasReconnectHandlersParams): {
onReconnectStart: () => void; onReconnectStart: () => void;
onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void;
@@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({
>(null); >(null);
const onReconnectStart = useCallback(() => { const onReconnectStart = useCallback(() => {
clearActiveMagnetTarget?.();
edgeReconnectSuccessful.current = false; edgeReconnectSuccessful.current = false;
isReconnectDragActiveRef.current = true; isReconnectDragActiveRef.current = true;
pendingReconnectRef.current = null; pendingReconnectRef.current = null;
}, [edgeReconnectSuccessful, isReconnectDragActiveRef]); }, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]);
const onReconnect = useCallback( const onReconnect = useCallback(
(oldEdge: RFEdge, newConnection: Connection) => { (oldEdge: RFEdge, newConnection: Connection) => {
@@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({
edgeReconnectSuccessful.current = true; edgeReconnectSuccessful.current = true;
} finally { } finally {
clearActiveMagnetTarget?.();
isReconnectDragActiveRef.current = false; isReconnectDragActiveRef.current = false;
} }
}, },
[ [
canvasId, canvasId,
clearActiveMagnetTarget,
edgeReconnectSuccessful, edgeReconnectSuccessful,
isReconnectDragActiveRef, isReconnectDragActiveRef,
runCreateEdgeMutation, runCreateEdgeMutation,

View File

@@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
interface CanvasInnerProps { interface CanvasInnerProps {
canvasId: Id<"canvases">; canvasId: Id<"canvases">;
@@ -709,7 +710,9 @@ interface CanvasProps {
export default function Canvas({ canvasId }: CanvasProps) { export default function Canvas({ canvasId }: CanvasProps) {
return ( return (
<ReactFlowProvider> <ReactFlowProvider>
<CanvasInner canvasId={canvasId} /> <CanvasConnectionMagnetismProvider>
<CanvasInner canvasId={canvasId} />
</CanvasConnectionMagnetismProvider>
</ReactFlowProvider> </ReactFlowProvider>
); );
} }

View File

@@ -10,6 +10,10 @@ import type { CanvasConnectionValidationReason } from "@/lib/canvas-connection-p
import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates"; import type { CanvasNodeTemplate } from "@/lib/canvas-node-templates";
import type { CanvasNodeType } from "@/lib/canvas-node-types"; import type { CanvasNodeType } from "@/lib/canvas-node-types";
import {
resolveCanvasMagnetTarget,
type CanvasMagnetTarget,
} from "./canvas-connection-magnetism";
import { import {
getConnectEndClientPoint, getConnectEndClientPoint,
hasHandleKey, hasHandleKey,
@@ -24,6 +28,7 @@ import {
validateCanvasConnectionByType, validateCanvasConnectionByType,
validateCanvasEdgeSplit, validateCanvasEdgeSplit,
} from "./canvas-connection-validation"; } from "./canvas-connection-validation";
import { useCanvasConnectionMagnetism } from "./canvas-connection-magnetism-context";
import { useCanvasReconnectHandlers } from "./canvas-reconnect"; import { useCanvasReconnectHandlers } from "./canvas-reconnect";
import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu"; import type { ConnectionDropMenuState } from "./canvas-connection-drop-menu";
@@ -122,6 +127,7 @@ export function useCanvasConnections({
runSwapMixerInputsMutation, runSwapMixerInputsMutation,
showConnectionRejectedToast, showConnectionRejectedToast,
}: UseCanvasConnectionsParams) { }: UseCanvasConnectionsParams) {
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const [connectionDropMenu, setConnectionDropMenu] = const [connectionDropMenu, setConnectionDropMenu] =
useState<ConnectionDropMenuState | null>(null); useState<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null); const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
@@ -133,56 +139,82 @@ export function useCanvasConnections({
}, [connectionDropMenu]); }, [connectionDropMenu]);
const onConnectStart = useCallback<OnConnectStart>((_event, params) => { const onConnectStart = useCallback<OnConnectStart>((_event, params) => {
setActiveTarget(null);
isConnectDragActiveRef.current = true; isConnectDragActiveRef.current = true;
logCanvasConnectionDebug("connect:start", { logCanvasConnectionDebug("connect:start", {
nodeId: params.nodeId, nodeId: params.nodeId,
handleId: params.handleId, handleId: params.handleId,
handleType: params.handleType, 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( const onConnect = useCallback(
(connection: Connection) => { (connection: Connection) => {
isConnectDragActiveRef.current = false; isConnectDragActiveRef.current = false;
const validationError = validateCanvasConnection(connection, nodes, edges); try {
if (validationError) { const validationError = validateCanvasConnection(connection, nodes, edges);
logCanvasConnectionDebug("connect:invalid-direct", { if (validationError) {
sourceNodeId: connection.source ?? null, logCanvasConnectionDebug("connect:invalid-direct", {
targetNodeId: connection.target ?? null, sourceNodeId: connection.source ?? null,
sourceHandle: connection.sourceHandle ?? null, targetNodeId: connection.target ?? null,
targetHandle: connection.targetHandle ?? null, sourceHandle: connection.sourceHandle ?? null,
validationError, targetHandle: connection.targetHandle ?? null,
}); validationError,
showConnectionRejectedToast(validationError); });
return; showConnectionRejectedToast(validationError);
} return;
}
if (!connection.source || !connection.target) { if (!connection.source || !connection.target) {
logCanvasConnectionDebug("connect:missing-endpoint", { logCanvasConnectionDebug("connect:missing-endpoint", {
sourceNodeId: connection.source ?? null, sourceNodeId: connection.source ?? null,
targetNodeId: connection.target ?? 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, sourceHandle: connection.sourceHandle ?? null,
targetHandle: connection.targetHandle ?? 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( const resolveMixerSwapReconnect = useCallback(
@@ -252,6 +284,7 @@ export function useCanvasConnections({
const onConnectEnd = useCallback<OnConnectEnd>( const onConnectEnd = useCallback<OnConnectEnd>(
(event, connectionState) => { (event, connectionState) => {
if (!isConnectDragActiveRef.current) { if (!isConnectDragActiveRef.current) {
setActiveTarget(null);
logCanvasConnectionDebug("connect:end-ignored", { logCanvasConnectionDebug("connect:end-ignored", {
reason: "drag-not-active", reason: "drag-not-active",
isValid: connectionState.isValid ?? null, isValid: connectionState.isValid ?? null,
@@ -264,187 +297,213 @@ export function useCanvasConnections({
} }
isConnectDragActiveRef.current = false; isConnectDragActiveRef.current = false;
if (isReconnectDragActiveRef.current) { try {
logCanvasConnectionDebug("connect:end-ignored", { if (isReconnectDragActiveRef.current) {
reason: "reconnect-active", logCanvasConnectionDebug("connect:end-ignored", {
isValid: connectionState.isValid ?? null, reason: "reconnect-active",
fromNodeId: connectionState.fromNode?.id ?? null, isValid: connectionState.isValid ?? null,
fromHandleId: connectionState.fromHandle?.id ?? null, fromNodeId: connectionState.fromNode?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null, fromHandleId: connectionState.fromHandle?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null, toNodeId: connectionState.toNode?.id ?? null,
}); toHandleId: connectionState.toHandle?.id ?? null,
return; });
} return;
if (connectionState.isValid === true) { }
logCanvasConnectionDebug("connect:end-ignored", { if (connectionState.isValid === true) {
reason: "react-flow-valid-connection", logCanvasConnectionDebug("connect:end-ignored", {
fromNodeId: connectionState.fromNode?.id ?? null, reason: "react-flow-valid-connection",
fromHandleId: connectionState.fromHandle?.id ?? null, fromNodeId: connectionState.fromNode?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null, fromHandleId: connectionState.fromHandle?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null, toNodeId: connectionState.toNode?.id ?? null,
}); toHandleId: connectionState.toHandle?.id ?? null,
return; });
} return;
const fromNode = connectionState.fromNode; }
const fromHandle = connectionState.fromHandle; const fromNode = connectionState.fromNode;
if (!fromNode || !fromHandle) { const fromHandle = connectionState.fromHandle;
logCanvasConnectionDebug("connect:end-aborted", { if (!fromNode || !fromHandle) {
reason: "missing-from-node-or-handle", logCanvasConnectionDebug("connect:end-aborted", {
fromNodeId: fromNode?.id ?? null, reason: "missing-from-node-or-handle",
fromHandleId: fromHandle?.id ?? null, fromNodeId: fromNode?.id ?? null,
toNodeId: connectionState.toNode?.id ?? null, fromHandleId: fromHandle?.id ?? null,
toHandleId: connectionState.toHandle?.id ?? null, toNodeId: connectionState.toNode?.id ?? null,
}); toHandleId: connectionState.toHandle?.id ?? null,
return; });
} return;
}
const pt = getConnectEndClientPoint(event); const pt = getConnectEndClientPoint(event);
if (!pt) { if (!pt) {
logCanvasConnectionDebug("connect:end-aborted", { logCanvasConnectionDebug("connect:end-aborted", {
reason: "missing-client-point", 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, fromNodeId: fromNode.id,
fromHandleId: fromHandle.id ?? null, fromHandleId: fromHandle.id ?? null,
fromHandleType: fromHandle.type, fromHandleType: fromHandle.type,
}); });
return;
}
logCanvasConnectionDebug("connect:end", { setConnectionDropMenu({
point: pt, screenX: pt.x,
fromNodeId: fromNode.id, screenY: pt.y,
fromHandleId: fromHandle.id ?? null, flowX: flow.x,
fromHandleType: fromHandle.type, flowY: flow.y,
toNodeId: connectionState.toNode?.id ?? null, fromNodeId: fromNode.id as Id<"nodes">,
toHandleId: connectionState.toHandle?.id ?? null, fromHandleId: fromHandle.id ?? undefined,
}); fromHandleType: fromHandle.type,
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,
}); });
} finally {
void runCreateEdgeMutation({ setActiveTarget(null);
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,
});
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, canvasId,
@@ -454,7 +513,10 @@ export function useCanvasConnections({
runCreateEdgeMutation, runCreateEdgeMutation,
runSplitEdgeAtExistingNodeMutation, runSplitEdgeAtExistingNodeMutation,
screenToFlowPosition, screenToFlowPosition,
setActiveTarget,
showConnectionRejectedToast, showConnectionRejectedToast,
activeTarget,
toDroppedConnectionFromMagnetTarget,
], ],
); );
@@ -598,6 +660,9 @@ export function useCanvasConnections({
onInvalidConnection: (reason) => { onInvalidConnection: (reason) => {
showConnectionRejectedToast(reason as CanvasConnectionValidationReason); showConnectionRejectedToast(reason as CanvasConnectionValidationReason);
}, },
clearActiveMagnetTarget: () => {
setActiveTarget(null);
},
}); });
return { return {