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

@@ -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<ConnectionDropMenuState | null>(null);
const connectionDropMenuRef = useRef<ConnectionDropMenuState | null>(null);
@@ -133,56 +139,82 @@ export function useCanvasConnections({
}, [connectionDropMenu]);
const onConnectStart = useCallback<OnConnectStart>((_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<OnConnectEnd>(
(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 {