feat(canvas): add proximity magnet target resolver

This commit is contained in:
2026-04-11 08:33:27 +02:00
parent 028fce35c2
commit 52d5d487b8
4 changed files with 587 additions and 70 deletions

View File

@@ -0,0 +1,198 @@
import type { Connection, Edge as RFEdge, Node as RFNode } from "@xyflow/react";
import { validateCanvasConnectionPolicy } from "@/lib/canvas-connection-policy";
export const HANDLE_GLOW_RADIUS_PX = 56;
export const HANDLE_SNAP_RADIUS_PX = 40;
export type CanvasMagnetTarget = {
nodeId: string;
handleId?: string;
handleType: "source" | "target";
centerX: number;
centerY: number;
distancePx: number;
};
type HandleCandidate = CanvasMagnetTarget & {
index: number;
};
function isOptimisticEdgeId(id: string): boolean {
return id.startsWith("optimistic_edge_");
}
function normalizeHandleId(value: string | undefined): string | undefined {
if (value === undefined || value === "") {
return undefined;
}
return value;
}
function isValidConnectionCandidate(args: {
connection: Connection;
nodes: RFNode[];
edges: RFEdge[];
}): boolean {
const { connection, nodes, edges } = args;
if (!connection.source || !connection.target) {
return false;
}
if (connection.source === connection.target) {
return false;
}
const sourceNode = nodes.find((node) => node.id === connection.source);
const targetNode = nodes.find((node) => node.id === connection.target);
if (!sourceNode || !targetNode) {
return false;
}
const incomingEdges = edges.filter(
(edge) =>
edge.className !== "temp" &&
!isOptimisticEdgeId(edge.id) &&
edge.target === connection.target,
);
return (
validateCanvasConnectionPolicy({
sourceType: sourceNode.type ?? "",
targetType: targetNode.type ?? "",
targetHandle: connection.targetHandle,
targetIncomingCount: incomingEdges.length,
targetIncomingHandles: incomingEdges.map((edge) => edge.targetHandle),
}) === null
);
}
function collectHandleCandidates(args: {
point: { x: number; y: number };
expectedHandleType: "source" | "target";
maxDistancePx: number;
handleElements?: Element[];
}): HandleCandidate[] {
const { point, expectedHandleType, maxDistancePx } = args;
const handleElements =
args.handleElements ??
(typeof document === "undefined"
? []
: Array.from(document.querySelectorAll("[data-node-id][data-handle-type]")));
const candidates: HandleCandidate[] = [];
let index = 0;
for (const element of handleElements) {
if (!(element instanceof HTMLElement)) {
continue;
}
if (element.dataset.handleType !== expectedHandleType) {
continue;
}
const nodeId = element.dataset.nodeId;
if (!nodeId) {
continue;
}
const rect = element.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distancePx = Math.hypot(point.x - centerX, point.y - centerY);
if (distancePx > maxDistancePx) {
continue;
}
candidates.push({
index,
nodeId,
handleId: normalizeHandleId(element.dataset.handleId),
handleType: expectedHandleType,
centerX,
centerY,
distancePx,
});
index += 1;
}
return candidates;
}
function toConnectionFromCandidate(args: {
fromNodeId: string;
fromHandleId?: string;
fromHandleType: "source" | "target";
candidate: HandleCandidate;
}): Connection {
if (args.fromHandleType === "source") {
return {
source: args.fromNodeId,
sourceHandle: args.fromHandleId ?? null,
target: args.candidate.nodeId,
targetHandle: args.candidate.handleId ?? null,
};
}
return {
source: args.candidate.nodeId,
sourceHandle: args.candidate.handleId ?? null,
target: args.fromNodeId,
targetHandle: args.fromHandleId ?? null,
};
}
export function resolveCanvasMagnetTarget(args: {
point: { x: number; y: number };
fromNodeId: string;
fromHandleId?: string;
fromHandleType: "source" | "target";
nodes: RFNode[];
edges: RFEdge[];
maxDistancePx?: number;
handleElements?: Element[];
}): CanvasMagnetTarget | null {
const expectedHandleType = args.fromHandleType === "source" ? "target" : "source";
const maxDistancePx = args.maxDistancePx ?? HANDLE_GLOW_RADIUS_PX;
const candidates = collectHandleCandidates({
point: args.point,
expectedHandleType,
maxDistancePx,
handleElements: args.handleElements,
}).filter((candidate) => {
const connection = toConnectionFromCandidate({
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId,
fromHandleType: args.fromHandleType,
candidate,
});
return isValidConnectionCandidate({
connection,
nodes: args.nodes,
edges: args.edges,
});
});
if (candidates.length === 0) {
return null;
}
candidates.sort((a, b) => {
const distanceDelta = a.distancePx - b.distancePx;
if (Math.abs(distanceDelta) > Number.EPSILON) {
return distanceDelta;
}
return a.index - b.index;
});
const winner = candidates[0];
return {
nodeId: winner.nodeId,
handleId: winner.handleId,
handleType: winner.handleType,
centerX: winner.centerX,
centerY: winner.centerY,
distancePx: winner.distancePx,
};
}