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

@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode { function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
return { return {
id: overrides.id,
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: {}, data: {},
...overrides, ...overrides,
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
return element; return element;
} }
function makeHandleElement(args: {
nodeId: string;
handleType: "source" | "target";
handleId?: string;
rect: Partial<DOMRect>;
}): HTMLElement {
const element = document.createElement("div");
element.className = "react-flow__handle";
element.dataset.nodeId = args.nodeId;
element.dataset.handleType = args.handleType;
if (args.handleId !== undefined) {
element.dataset.handleId = args.handleId;
}
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
x: args.rect.left ?? 0,
y: args.rect.top ?? 0,
top: args.rect.top ?? 0,
left: args.rect.left ?? 0,
right: args.rect.right ?? 10,
bottom: args.rect.bottom ?? 10,
width: args.rect.width ?? 10,
height: args.rect.height ?? 10,
toJSON: () => ({}),
} as DOMRect);
document.body.appendChild(element);
return element;
}
describe("resolveDroppedConnectionTarget", () => { describe("resolveDroppedConnectionTarget", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -144,6 +171,169 @@ describe("resolveDroppedConnectionTarget", () => {
}); });
}); });
it("resolves nearest valid target handle even without a node body hit", () => {
const sourceNode = createNode({
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
});
const compareNode = createNode({
id: "node-compare",
type: "compare",
position: { x: 320, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "left",
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "right",
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
});
const result = resolveDroppedConnectionTarget({
point: { x: 364, y: 258 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, compareNode],
edges: [],
});
expect(result).toEqual({
sourceNodeId: "node-source",
targetNodeId: "node-compare",
sourceHandle: undefined,
targetHandle: "left",
});
});
it("skips a closer invalid handle and picks the nearest valid handle", () => {
const sourceNode = createNode({
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
});
const mixerNode = createNode({
id: "node-mixer",
type: "mixer",
position: { x: 320, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "base",
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "overlay",
rect: { left: 386, top: 278, width: 12, height: 12, right: 398, bottom: 290 },
});
const result = resolveDroppedConnectionTarget({
point: { x: 364, y: 258 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, mixerNode],
edges: [
createEdge({
id: "edge-base-taken",
source: "node-source",
target: "node-mixer",
targetHandle: "base",
}),
],
});
expect(result).toEqual({
sourceNodeId: "node-source",
targetNodeId: "node-mixer",
sourceHandle: undefined,
targetHandle: "overlay",
});
});
it("prefers the actually nearest handle for compare and mixer targets", () => {
const sourceNode = createNode({
id: "node-source",
type: "image",
position: { x: 0, y: 0 },
});
const compareNode = createNode({
id: "node-compare",
type: "compare",
position: { x: 320, y: 200 },
});
const mixerNode = createNode({
id: "node-mixer",
type: "mixer",
position: { x: 640, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "left",
rect: { left: 358, top: 252, width: 12, height: 12, right: 370, bottom: 264 },
});
makeHandleElement({
nodeId: "node-compare",
handleType: "target",
handleId: "right",
rect: { left: 438, top: 332, width: 12, height: 12, right: 450, bottom: 344 },
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "base",
rect: { left: 678, top: 252, width: 12, height: 12, right: 690, bottom: 264 },
});
makeHandleElement({
nodeId: "node-mixer",
handleType: "target",
handleId: "overlay",
rect: { left: 678, top: 292, width: 12, height: 12, right: 690, bottom: 304 },
});
const compareResult = resolveDroppedConnectionTarget({
point: { x: 364, y: 258 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, compareNode, mixerNode],
edges: [],
});
const mixerResult = resolveDroppedConnectionTarget({
point: { x: 684, y: 299 },
fromNodeId: "node-source",
fromHandleType: "source",
nodes: [sourceNode, compareNode, mixerNode],
edges: [],
});
expect(compareResult?.targetHandle).toBe("left");
expect(mixerResult?.targetHandle).toBe("overlay");
});
it("reverses the connection when the drag starts from a target handle", () => { it("reverses the connection when the drag starts from a target handle", () => {
const droppedNode = createNode({ const droppedNode = createNode({
id: "node-dropped", id: "node-dropped",
@@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => {
targetHandle: "target-handle", targetHandle: "target-handle",
}); });
}); });
it("resolves nearest source handle when drag starts from target handle", () => {
const fromNode = createNode({
id: "node-compare-target",
type: "compare",
position: { x: 0, y: 0 },
});
const compareNode = createNode({
id: "node-compare-source",
type: "compare",
position: { x: 320, y: 200 },
});
Object.defineProperty(document, "elementsFromPoint", {
value: vi.fn(() => []),
configurable: true,
});
makeHandleElement({
nodeId: "node-compare-source",
handleType: "source",
handleId: "compare-out",
rect: { left: 478, top: 288, width: 12, height: 12, right: 490, bottom: 300 },
});
const result = resolveDroppedConnectionTarget({
point: { x: 484, y: 294 },
fromNodeId: "node-compare-target",
fromHandleId: "left",
fromHandleType: "target",
nodes: [fromNode, compareNode],
edges: [],
});
expect(result).toEqual({
sourceNodeId: "node-compare-source",
targetNodeId: "node-compare-target",
sourceHandle: "compare-out",
targetHandle: "left",
});
});
}); });

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,
};
}

View File

@@ -8,6 +8,7 @@ import {
getSourceImageFromGraph, getSourceImageFromGraph,
} from "@/lib/canvas-render-preview"; } from "@/lib/canvas-render-preview";
import { NODE_HANDLE_MAP } from "@/lib/canvas-utils"; import { NODE_HANDLE_MAP } from "@/lib/canvas-utils";
import { resolveCanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism";
export const OPTIMISTIC_NODE_PREFIX = "optimistic_"; export const OPTIMISTIC_NODE_PREFIX = "optimistic_";
export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_"; export const OPTIMISTIC_EDGE_PREFIX = "optimistic_edge_";
@@ -421,17 +422,7 @@ export function resolveDroppedConnectionTarget(args: {
? [] ? []
: document.elementsFromPoint(args.point.x, args.point.y); : document.elementsFromPoint(args.point.x, args.point.y);
const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint); const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint);
if (!nodeElement) { if (nodeElement) {
logCanvasConnectionDebug("drop-target:node-missed", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
});
return null;
}
const targetNodeId = nodeElement.dataset.id; const targetNodeId = nodeElement.dataset.id;
if (!targetNodeId) { if (!targetNodeId) {
logCanvasConnectionDebug("drop-target:node-missing-data-id", { logCanvasConnectionDebug("drop-target:node-missing-data-id", {
@@ -511,6 +502,65 @@ export function resolveDroppedConnectionTarget(args: {
return droppedConnection; return droppedConnection;
} }
const magnetTarget = resolveCanvasMagnetTarget({
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId,
fromHandleType: args.fromHandleType,
nodes: args.nodes,
edges: args.edges,
});
if (!magnetTarget) {
logCanvasConnectionDebug("drop-target:node-missed", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
elementsAtPoint: elementsAtPoint.slice(0, 6).map(describeConnectionDebugElement),
});
return null;
}
if (args.fromHandleType === "source") {
const droppedConnection = {
sourceNodeId: args.fromNodeId,
targetNodeId: magnetTarget.nodeId,
sourceHandle: args.fromHandleId,
targetHandle: magnetTarget.handleId,
};
logCanvasConnectionDebug("drop-target:magnet-detected", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
magnetTarget,
resolvedConnection: droppedConnection,
});
return droppedConnection;
}
const droppedConnection = {
sourceNodeId: magnetTarget.nodeId,
targetNodeId: args.fromNodeId,
sourceHandle: magnetTarget.handleId,
targetHandle: args.fromHandleId,
};
logCanvasConnectionDebug("drop-target:magnet-detected", {
point: args.point,
fromNodeId: args.fromNodeId,
fromHandleId: args.fromHandleId ?? null,
fromHandleType: args.fromHandleType,
magnetTarget,
resolvedConnection: droppedConnection,
});
return droppedConnection;
}
/** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */ /** Kanten-Split nach Drag: wartet auf echte Node-ID, wenn der Knoten noch optimistisch ist. */
export type PendingEdgeSplit = { export type PendingEdgeSplit = {
intersectedEdgeId: Id<"edges">; intersectedEdgeId: Id<"edges">;

View File

@@ -102,7 +102,9 @@ export function convexEdgeToRF(edge: Doc<"edges">): RFEdge {
* Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente). * Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente).
* Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad). * Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad).
*/ */
const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> = { type RgbColor = readonly [number, number, number];
const SOURCE_NODE_GLOW_RGB: Record<string, RgbColor> = {
prompt: [139, 92, 246], prompt: [139, 92, 246],
"video-prompt": [124, 58, 237], "video-prompt": [124, 58, 237],
"ai-image": [139, 92, 246], "ai-image": [139, 92, 246],
@@ -123,21 +125,59 @@ const SOURCE_NODE_GLOW_RGB: Record<string, readonly [number, number, number]> =
render: [14, 165, 233], render: [14, 165, 233],
agent: [245, 158, 11], agent: [245, 158, 11],
"agent-output": [245, 158, 11], "agent-output": [245, 158, 11],
mixer: [100, 116, 139],
}; };
/** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */ /** Compare: Ziel-Handles blau/smaragd, Quelle compare-out grau (wie in compare-node.tsx). */
const COMPARE_HANDLE_CONNECTION_RGB: Record< const COMPARE_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
string,
readonly [number, number, number]
> = {
left: [59, 130, 246], left: [59, 130, 246],
right: [16, 185, 129], right: [16, 185, 129],
"compare-out": [100, 116, 139], "compare-out": [100, 116, 139],
}; };
const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [ const MIXER_HANDLE_CONNECTION_RGB: Record<string, RgbColor> = {
13, 148, 136, base: [14, 165, 233],
]; overlay: [236, 72, 153],
"mixer-out": [100, 116, 139],
};
const CONNECTION_LINE_FALLBACK_RGB: RgbColor = [13, 148, 136];
export function canvasHandleAccentRgb(args: {
nodeType: string | undefined;
handleId?: string | null;
handleType: "source" | "target";
}): RgbColor {
const nodeType = args.nodeType;
const handleId = args.handleId ?? undefined;
const handleType = args.handleType;
if (nodeType === "compare" && handleId) {
if (handleType === "target" && handleId === "compare-out") {
return SOURCE_NODE_GLOW_RGB.compare;
}
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) {
return byHandle;
}
}
if (nodeType === "mixer" && handleId) {
if (handleType === "target" && handleId === "mixer-out") {
return SOURCE_NODE_GLOW_RGB.mixer;
}
const byHandle = MIXER_HANDLE_CONNECTION_RGB[handleId];
if (byHandle) {
return byHandle;
}
}
if (!nodeType) {
return CONNECTION_LINE_FALLBACK_RGB;
}
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
}
/** /**
* RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect). * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
@@ -145,13 +185,12 @@ const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [
export function connectionLineAccentRgb( export function connectionLineAccentRgb(
nodeType: string | undefined, nodeType: string | undefined,
handleId: string | null | undefined, handleId: string | null | undefined,
): readonly [number, number, number] { ): RgbColor {
if (nodeType === "compare" && handleId) { return canvasHandleAccentRgb({
const byHandle = COMPARE_HANDLE_CONNECTION_RGB[handleId]; nodeType,
if (byHandle) return byHandle; handleId,
} handleType: "source",
if (!nodeType) return CONNECTION_LINE_FALLBACK_RGB; });
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
} }
export type EdgeGlowColorMode = "light" | "dark"; export type EdgeGlowColorMode = "light" | "dark";