feat(canvas): add proximity magnet target resolver
This commit is contained in:
198
components/canvas/canvas-connection-magnetism.ts
Normal file
198
components/canvas/canvas-connection-magnetism.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user