diff --git a/components/canvas/__tests__/canvas-connection-drop-target.test.tsx b/components/canvas/__tests__/canvas-connection-drop-target.test.tsx index 4139042..e76a4ab 100644 --- a/components/canvas/__tests__/canvas-connection-drop-target.test.tsx +++ b/components/canvas/__tests__/canvas-connection-drop-target.test.tsx @@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe function createNode(overrides: Partial & Pick): RFNode { return { - id: overrides.id, position: { x: 0, y: 0 }, data: {}, ...overrides, @@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial = {}): HTMLElement { return element; } +function makeHandleElement(args: { + nodeId: string; + handleType: "source" | "target"; + handleId?: string; + rect: Partial; +}): 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", () => { afterEach(() => { 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", () => { const droppedNode = createNode({ id: "node-dropped", @@ -177,4 +367,44 @@ describe("resolveDroppedConnectionTarget", () => { 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", + }); + }); }); diff --git a/components/canvas/canvas-connection-magnetism.ts b/components/canvas/canvas-connection-magnetism.ts new file mode 100644 index 0000000..468049b --- /dev/null +++ b/components/canvas/canvas-connection-magnetism.ts @@ -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, + }; +} diff --git a/components/canvas/canvas-helpers.ts b/components/canvas/canvas-helpers.ts index 816fe6f..dbfd460 100644 --- a/components/canvas/canvas-helpers.ts +++ b/components/canvas/canvas-helpers.ts @@ -8,6 +8,7 @@ import { getSourceImageFromGraph, } from "@/lib/canvas-render-preview"; 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_EDGE_PREFIX = "optimistic_edge_"; @@ -421,59 +422,70 @@ export function resolveDroppedConnectionTarget(args: { ? [] : document.elementsFromPoint(args.point.x, args.point.y); const nodeElement = getNodeElementAtClientPoint(args.point, elementsAtPoint); - 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; - } + if (nodeElement) { + const targetNodeId = nodeElement.dataset.id; + if (!targetNodeId) { + logCanvasConnectionDebug("drop-target:node-missing-data-id", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + nodeElement: describeConnectionDebugElement(nodeElement), + }); + return null; + } - const targetNodeId = nodeElement.dataset.id; - if (!targetNodeId) { - logCanvasConnectionDebug("drop-target:node-missing-data-id", { - point: args.point, - fromNodeId: args.fromNodeId, - fromHandleId: args.fromHandleId ?? null, - fromHandleType: args.fromHandleType, - nodeElement: describeConnectionDebugElement(nodeElement), - }); - return null; - } + const targetNode = args.nodes.find((node) => node.id === targetNodeId); + if (!targetNode) { + logCanvasConnectionDebug("drop-target:node-not-in-state", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + targetNodeId, + nodeCount: args.nodes.length, + nodeElement: describeConnectionDebugElement(nodeElement), + }); + return null; + } - const targetNode = args.nodes.find((node) => node.id === targetNodeId); - if (!targetNode) { - logCanvasConnectionDebug("drop-target:node-not-in-state", { - point: args.point, - fromNodeId: args.fromNodeId, - fromHandleId: args.fromHandleId ?? null, - fromHandleType: args.fromHandleType, - targetNodeId, - nodeCount: args.nodes.length, - nodeElement: describeConnectionDebugElement(nodeElement), - }); - return null; - } + const handles = NODE_HANDLE_MAP[targetNode.type ?? ""]; - const handles = NODE_HANDLE_MAP[targetNode.type ?? ""]; + if (args.fromHandleType === "source") { + const droppedConnection = { + sourceNodeId: args.fromNodeId, + targetNodeId, + sourceHandle: args.fromHandleId, + targetHandle: + targetNode.type === "compare" + ? getCompareBodyDropTargetHandle({ + point: args.point, + nodeElement, + targetNodeId, + edges: args.edges, + }) + : handles?.target, + }; + + logCanvasConnectionDebug("drop-target:node-detected", { + point: args.point, + fromNodeId: args.fromNodeId, + fromHandleId: args.fromHandleId ?? null, + fromHandleType: args.fromHandleType, + targetNodeId, + targetNodeType: targetNode.type ?? null, + nodeElement: describeConnectionDebugElement(nodeElement), + resolvedConnection: droppedConnection, + }); + + return droppedConnection; + } - if (args.fromHandleType === "source") { const droppedConnection = { - sourceNodeId: args.fromNodeId, - targetNodeId, - sourceHandle: args.fromHandleId, - targetHandle: - targetNode.type === "compare" - ? getCompareBodyDropTargetHandle({ - point: args.point, - nodeElement, - targetNodeId, - edges: args.edges, - }) - : handles?.target, + sourceNodeId: targetNodeId, + targetNodeId: args.fromNodeId, + sourceHandle: handles?.source, + targetHandle: args.fromHandleId, }; logCanvasConnectionDebug("drop-target:node-detected", { @@ -490,21 +502,59 @@ export function resolveDroppedConnectionTarget(args: { 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: targetNodeId, + sourceNodeId: magnetTarget.nodeId, targetNodeId: args.fromNodeId, - sourceHandle: handles?.source, + sourceHandle: magnetTarget.handleId, targetHandle: args.fromHandleId, }; - logCanvasConnectionDebug("drop-target:node-detected", { + logCanvasConnectionDebug("drop-target:magnet-detected", { point: args.point, fromNodeId: args.fromNodeId, fromHandleId: args.fromHandleId ?? null, fromHandleType: args.fromHandleType, - targetNodeId, - targetNodeType: targetNode.type ?? null, - nodeElement: describeConnectionDebugElement(nodeElement), + magnetTarget, resolvedConnection: droppedConnection, }); diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index 3a617e6..e1f0a21 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -102,7 +102,9 @@ export function convexEdgeToRF(edge: Doc<"edges">): RFEdge { * Akzentfarben der Handles je Node-Typ (s. jeweilige Node-Komponente). * Für einen dezenten Glow entlang der Kante (drop-shadow am Pfad). */ -const SOURCE_NODE_GLOW_RGB: Record = { +type RgbColor = readonly [number, number, number]; + +const SOURCE_NODE_GLOW_RGB: Record = { prompt: [139, 92, 246], "video-prompt": [124, 58, 237], "ai-image": [139, 92, 246], @@ -123,21 +125,59 @@ const SOURCE_NODE_GLOW_RGB: Record = render: [14, 165, 233], agent: [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). */ -const COMPARE_HANDLE_CONNECTION_RGB: Record< - string, - readonly [number, number, number] -> = { +const COMPARE_HANDLE_CONNECTION_RGB: Record = { left: [59, 130, 246], right: [16, 185, 129], "compare-out": [100, 116, 139], }; -const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [ - 13, 148, 136, -]; +const MIXER_HANDLE_CONNECTION_RGB: Record = { + 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). @@ -145,13 +185,12 @@ const CONNECTION_LINE_FALLBACK_RGB: readonly [number, number, number] = [ export function connectionLineAccentRgb( nodeType: string | undefined, handleId: string | null | undefined, -): readonly [number, number, number] { - if (nodeType === "compare" && handleId) { - const byHandle = COMPARE_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; +): RgbColor { + return canvasHandleAccentRgb({ + nodeType, + handleId, + handleType: "source", + }); } export type EdgeGlowColorMode = "light" | "dark";