From 52d5d487b831b84b4ec374bc1cf2b1883518326a Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 08:33:27 +0200 Subject: [PATCH 1/9] feat(canvas): add proximity magnet target resolver --- .../canvas-connection-drop-target.test.tsx | 232 +++++++++++++++++- .../canvas/canvas-connection-magnetism.ts | 198 +++++++++++++++ components/canvas/canvas-helpers.ts | 158 ++++++++---- lib/canvas-utils.ts | 69 ++++-- 4 files changed, 587 insertions(+), 70 deletions(-) create mode 100644 components/canvas/canvas-connection-magnetism.ts 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"; From 1d691999ddf76de0c1ec6133e865e1dff0394303 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 08:41:14 +0200 Subject: [PATCH 2/9] feat(canvas): share magnet state across connection drags --- .../__tests__/use-canvas-connections.test.tsx | 304 ++++++++++- .../canvas-connection-magnetism-context.tsx | 51 ++ components/canvas/canvas-reconnect.ts | 7 +- components/canvas/canvas.tsx | 5 +- components/canvas/use-canvas-connections.ts | 479 ++++++++++-------- 5 files changed, 635 insertions(+), 211 deletions(-) create mode 100644 components/canvas/canvas-connection-magnetism-context.tsx diff --git a/components/canvas/__tests__/use-canvas-connections.test.tsx b/components/canvas/__tests__/use-canvas-connections.test.tsx index 2d67fad..60f9720 100644 --- a/components/canvas/__tests__/use-canvas-connections.test.tsx +++ b/components/canvas/__tests__/use-canvas-connections.test.tsx @@ -6,9 +6,11 @@ import type { Edge as RFEdge, Node as RFNode } from "@xyflow/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Id } from "@/convex/_generated/dataModel"; +import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism"; const mocks = vi.hoisted(() => ({ resolveDroppedConnectionTarget: vi.fn(), + resolveCanvasMagnetTarget: vi.fn(), })); vi.mock("@/components/canvas/canvas-helpers", async () => { @@ -22,8 +24,23 @@ vi.mock("@/components/canvas/canvas-helpers", async () => { }; }); +vi.mock("@/components/canvas/canvas-connection-magnetism", async () => { + const actual = await vi.importActual< + typeof import("@/components/canvas/canvas-connection-magnetism") + >("@/components/canvas/canvas-connection-magnetism"); + + return { + ...actual, + resolveCanvasMagnetTarget: mocks.resolveCanvasMagnetTarget, + }; +}); + import { useCanvasConnections } from "@/components/canvas/use-canvas-connections"; import type { DroppedConnectionTarget } from "@/components/canvas/canvas-helpers"; +import { + CanvasConnectionMagnetismProvider, + useCanvasConnectionMagnetism, +} from "@/components/canvas/canvas-connection-magnetism-context"; import { nodeTypes } from "@/components/canvas/node-types"; import { NODE_CATALOG } from "@/lib/canvas-node-catalog"; import { CANVAS_NODE_TEMPLATES } from "@/lib/canvas-node-templates"; @@ -35,6 +52,14 @@ const latestHandlersRef: { current: ReturnType | null; } = { current: null }; +const latestMagnetTargetRef: { + current: CanvasMagnetTarget | null; +} = { current: null }; + +const latestSetActiveTargetRef: { + current: ((target: CanvasMagnetTarget | null) => void) | null; +} = { current: null }; + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type HookHarnessProps = { @@ -47,9 +72,12 @@ type HookHarnessProps = { setEdgesMock?: ReturnType; nodes?: RFNode[]; edges?: RFEdge[]; + initialMagnetTarget?: CanvasMagnetTarget | null; }; -function HookHarness({ +type HookHarnessInnerProps = HookHarnessProps; + +function HookHarnessInner({ helperResult, runCreateEdgeMutation = vi.fn(async () => undefined), runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined), @@ -59,7 +87,10 @@ function HookHarness({ setEdgesMock, nodes: providedNodes, edges: providedEdges, -}: HookHarnessProps) { + initialMagnetTarget, +}: HookHarnessInnerProps) { + const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); + const didInitializeMagnetTargetRef = useRef(false); const [nodes] = useState( providedNodes ?? [ { id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} }, @@ -88,6 +119,17 @@ function HookHarness({ mocks.resolveDroppedConnectionTarget.mockReturnValue(helperResult); }, [helperResult]); + useEffect(() => { + mocks.resolveCanvasMagnetTarget.mockReturnValue(null); + }, []); + + useEffect(() => { + if (!didInitializeMagnetTargetRef.current && initialMagnetTarget !== undefined) { + didInitializeMagnetTargetRef.current = true; + setActiveTarget(initialMagnetTarget); + } + }, [initialMagnetTarget, setActiveTarget]); + const handlers = useCanvasConnections({ canvasId: asCanvasId("canvas-1"), nodes, @@ -115,15 +157,36 @@ function HookHarness({ latestHandlersRef.current = handlers; }, [handlers]); + useEffect(() => { + latestMagnetTargetRef.current = activeTarget; + }, [activeTarget]); + + useEffect(() => { + latestSetActiveTargetRef.current = setActiveTarget; + return () => { + latestSetActiveTargetRef.current = null; + }; + }, [setActiveTarget]); + return null; } +function HookHarness(props: HookHarnessProps) { + return ( + + + + ); +} + describe("useCanvasConnections", () => { let container: HTMLDivElement | null = null; let root: Root | null = null; afterEach(async () => { latestHandlersRef.current = null; + latestMagnetTargetRef.current = null; + latestSetActiveTargetRef.current = null; vi.clearAllMocks(); if (root) { await act(async () => { @@ -1253,4 +1316,241 @@ describe("useCanvasConnections", () => { expect(runSwapMixerInputsMutation).not.toHaveBeenCalled(); expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); }); + + it("falls back to active magnet target when direct drop resolution misses", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 400, clientY: 260 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 400, y: 260 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalledWith({ + canvasId: "canvas-1", + sourceNodeId: "node-source", + targetNodeId: "node-target", + sourceHandle: undefined, + targetHandle: "base", + }); + expect(latestMagnetTargetRef.current).toBeNull(); + }); + + it("rejects invalid active magnet target and clears transient state", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + const showConnectionRejectedToast = vi.fn(); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + latestHandlersRef.current?.onConnectEnd( + { clientX: 120, clientY: 120 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 120, y: 120 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(runCreateEdgeMutation).not.toHaveBeenCalled(); + expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop"); + expect(latestMagnetTargetRef.current).toBeNull(); + }); + + it("clears transient magnet state when dropping on background opens menu", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectStart?.( + {} as MouseEvent, + { + nodeId: "node-source", + handleId: null, + handleType: "source", + } as never, + ); + }); + + await act(async () => { + latestHandlersRef.current?.onConnectEnd( + { clientX: 500, clientY: 460 } as MouseEvent, + { + isValid: false, + from: { x: 0, y: 0 }, + fromNode: { id: "node-source", type: "image" }, + fromHandle: { id: null, type: "source" }, + fromPosition: null, + to: { x: 500, y: 460 }, + toHandle: null, + toNode: null, + toPosition: null, + pointer: null, + } as never, + ); + }); + + expect(latestHandlersRef.current?.connectionDropMenu).toEqual( + expect.objectContaining({ + screenX: 500, + screenY: 460, + }), + ); + expect(latestMagnetTargetRef.current).toBeNull(); + }); + + it("clears transient magnet state when reconnect drag ends", async () => { + const runCreateEdgeMutation = vi.fn(async () => undefined); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + }); + + const oldEdge = { + id: "edge-1", + source: "node-source", + target: "node-target", + targetHandle: "base", + } as RFEdge; + + await act(async () => { + latestHandlersRef.current?.onReconnectStart(); + latestHandlersRef.current?.onReconnect(oldEdge, { + source: "node-source", + target: "node-target", + sourceHandle: null, + targetHandle: "overlay", + }); + latestHandlersRef.current?.onReconnectEnd({} as MouseEvent, oldEdge); + await Promise.resolve(); + }); + + expect(runCreateEdgeMutation).toHaveBeenCalled(); + expect(latestMagnetTargetRef.current).toBeNull(); + }); }); diff --git a/components/canvas/canvas-connection-magnetism-context.tsx b/components/canvas/canvas-connection-magnetism-context.tsx new file mode 100644 index 0000000..e0b85aa --- /dev/null +++ b/components/canvas/canvas-connection-magnetism-context.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +import type { CanvasMagnetTarget } from "@/components/canvas/canvas-connection-magnetism"; + +type CanvasConnectionMagnetismState = { + activeTarget: CanvasMagnetTarget | null; + setActiveTarget: (target: CanvasMagnetTarget | null) => void; +}; + +const CanvasConnectionMagnetismContext = + createContext(null); + +export function CanvasConnectionMagnetismProvider({ + children, +}: { + children: ReactNode; +}) { + const [activeTarget, setActiveTarget] = useState(null); + + const value = useMemo( + () => ({ + activeTarget, + setActiveTarget, + }), + [activeTarget], + ); + + return ( + + {children} + + ); +} + +export function useCanvasConnectionMagnetism(): CanvasConnectionMagnetismState { + const context = useContext(CanvasConnectionMagnetismContext); + if (!context) { + throw new Error( + "useCanvasConnectionMagnetism must be used within CanvasConnectionMagnetismProvider", + ); + } + return context; +} diff --git a/components/canvas/canvas-reconnect.ts b/components/canvas/canvas-reconnect.ts index 000ec0b..52aaadb 100644 --- a/components/canvas/canvas-reconnect.ts +++ b/components/canvas/canvas-reconnect.ts @@ -39,6 +39,7 @@ type UseCanvasReconnectHandlersParams = { nextOtherEdgeHandle: "base" | "overlay"; } | null; onInvalidConnection?: (message: string) => void; + clearActiveMagnetTarget?: () => void; }; export function useCanvasReconnectHandlers({ @@ -52,6 +53,7 @@ export function useCanvasReconnectHandlers({ validateConnection, resolveMixerSwapReconnect, onInvalidConnection, + clearActiveMagnetTarget, }: UseCanvasReconnectHandlersParams): { onReconnectStart: () => void; onReconnect: (oldEdge: RFEdge, newConnection: Connection) => void; @@ -72,10 +74,11 @@ export function useCanvasReconnectHandlers({ >(null); const onReconnectStart = useCallback(() => { + clearActiveMagnetTarget?.(); edgeReconnectSuccessful.current = false; isReconnectDragActiveRef.current = true; pendingReconnectRef.current = null; - }, [edgeReconnectSuccessful, isReconnectDragActiveRef]); + }, [clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef]); const onReconnect = useCallback( (oldEdge: RFEdge, newConnection: Connection) => { @@ -201,11 +204,13 @@ export function useCanvasReconnectHandlers({ edgeReconnectSuccessful.current = true; } finally { + clearActiveMagnetTarget?.(); isReconnectDragActiveRef.current = false; } }, [ canvasId, + clearActiveMagnetTarget, edgeReconnectSuccessful, isReconnectDragActiveRef, runCreateEdgeMutation, diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 0eb8ae1..13ec262 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; +import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context"; interface CanvasInnerProps { canvasId: Id<"canvases">; @@ -709,7 +710,9 @@ interface CanvasProps { export default function Canvas({ canvasId }: CanvasProps) { return ( - + + + ); } diff --git a/components/canvas/use-canvas-connections.ts b/components/canvas/use-canvas-connections.ts index e3ea26c..3a98b31 100644 --- a/components/canvas/use-canvas-connections.ts +++ b/components/canvas/use-canvas-connections.ts @@ -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(null); const connectionDropMenuRef = useRef(null); @@ -133,56 +139,82 @@ export function useCanvasConnections({ }, [connectionDropMenu]); const onConnectStart = useCallback((_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( (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 { From ae76289e41f6bafb3ede1e58be7d95a652e70a39 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 08:47:30 +0200 Subject: [PATCH 3/9] feat(canvas): add shared glowing canvas handle --- .../canvas/__tests__/canvas-handle.test.tsx | 215 ++++++++++++++++++ components/canvas/canvas-handle.tsx | 85 +++++++ lib/canvas-utils.ts | 21 ++ vitest.config.ts | 1 + 4 files changed, 322 insertions(+) create mode 100644 components/canvas/__tests__/canvas-handle.test.tsx create mode 100644 components/canvas/canvas-handle.tsx diff --git a/components/canvas/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx new file mode 100644 index 0000000..972c47d --- /dev/null +++ b/components/canvas/__tests__/canvas-handle.test.tsx @@ -0,0 +1,215 @@ +// @vitest-environment jsdom + +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { HANDLE_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism"; +import { + CanvasConnectionMagnetismProvider, + useCanvasConnectionMagnetism, +} from "@/components/canvas/canvas-connection-magnetism-context"; + +const connectionStateRef: { current: { inProgress: boolean } } = { + current: { inProgress: false }, +}; + +vi.mock("@xyflow/react", () => ({ + Handle: ({ + className, + style, + ...props + }: React.HTMLAttributes & { + className?: string; + style?: React.CSSProperties; + }) =>
, + Position: { Left: "left", Right: "right" }, + useConnection: () => connectionStateRef.current, +})); + +import CanvasHandle from "@/components/canvas/canvas-handle"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function MagnetTargetSetter({ + target, +}: { + target: + | { + nodeId: string; + handleId?: string; + handleType: "source" | "target"; + centerX: number; + centerY: number; + distancePx: number; + } + | null; +}) { + const { setActiveTarget } = useCanvasConnectionMagnetism(); + + useEffect(() => { + setActiveTarget(target); + }, [setActiveTarget, target]); + + return null; +} + +describe("CanvasHandle", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + connectionStateRef.current = { inProgress: false }; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + }); + + async function renderHandle(args?: { + inProgress?: boolean; + activeTarget?: { + nodeId: string; + handleId?: string; + handleType: "source" | "target"; + centerX: number; + centerY: number; + distancePx: number; + } | null; + props?: Partial>; + }) { + connectionStateRef.current = { inProgress: args?.inProgress ?? false }; + + await act(async () => { + root?.render( + + + + , + ); + }); + } + + function getHandleElement() { + const handle = container?.querySelector("[data-node-id='node-1'][data-handle-type]"); + if (!(handle instanceof HTMLElement)) { + throw new Error("CanvasHandle element not found"); + } + return handle; + } + + it("renders default handle chrome with expected size and border", async () => { + await renderHandle(); + + const handle = getHandleElement(); + expect(handle.className).toContain("!h-3"); + expect(handle.className).toContain("!w-3"); + expect(handle.className).toContain("!border-2"); + expect(handle.className).toContain("!border-background"); + expect(handle.getAttribute("data-glow-state")).toBe("idle"); + }); + + it("turns on near-target glow when this handle is active target", async () => { + await renderHandle({ + inProgress: true, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX + 2, + }, + }); + + const handle = getHandleElement(); + expect(handle.getAttribute("data-glow-state")).toBe("near"); + }); + + it("renders a stronger glow in snapped state than near state", async () => { + await renderHandle({ + inProgress: true, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX + 6, + }, + }); + + const nearHandle = getHandleElement(); + const nearGlow = nearHandle.style.boxShadow; + + await renderHandle({ + inProgress: true, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX - 4, + }, + }); + + const snappedHandle = getHandleElement(); + expect(snappedHandle.getAttribute("data-glow-state")).toBe("snapped"); + expect(snappedHandle.style.boxShadow).not.toBe(nearGlow); + }); + + it("does not glow for non-target handles during the same drag", async () => { + await renderHandle({ + inProgress: true, + activeTarget: { + nodeId: "other-node", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX - 4, + }, + }); + + const handle = getHandleElement(); + expect(handle.getAttribute("data-glow-state")).toBe("idle"); + }); + + it("emits stable handle geometry data attributes", async () => { + await renderHandle({ + props: { + nodeId: "node-2", + id: undefined, + type: "source", + position: "right", + }, + }); + + const handle = container?.querySelector("[data-node-id='node-2'][data-handle-type='source']"); + if (!(handle instanceof HTMLElement)) { + throw new Error("CanvasHandle source element not found"); + } + + expect(handle.getAttribute("data-node-id")).toBe("node-2"); + expect(handle.getAttribute("data-handle-id")).toBe(""); + expect(handle.getAttribute("data-handle-type")).toBe("source"); + }); +}); diff --git a/components/canvas/canvas-handle.tsx b/components/canvas/canvas-handle.tsx new file mode 100644 index 0000000..82265a5 --- /dev/null +++ b/components/canvas/canvas-handle.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { Handle, useConnection } from "@xyflow/react"; + +import { HANDLE_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism"; +import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; +import { + canvasHandleAccentColor, + canvasHandleAccentColorWithAlpha, +} from "@/lib/canvas-utils"; +import { cn } from "@/lib/utils"; + +type ReactFlowHandleProps = React.ComponentProps; + +type CanvasHandleProps = Omit & { + nodeId: string; + nodeType?: string; + id?: string; +}; + +function normalizeHandleId(value: string | undefined): string | undefined { + return value === "" ? undefined : value; +} + +export default function CanvasHandle({ + nodeId, + nodeType, + id, + type, + className, + style, + ...rest +}: CanvasHandleProps) { + const connection = useConnection(); + const { activeTarget } = useCanvasConnectionMagnetism(); + + const handleId = normalizeHandleId(id); + const targetHandleId = normalizeHandleId(activeTarget?.handleId); + const isActiveTarget = + connection.inProgress && + activeTarget !== null && + activeTarget.nodeId === nodeId && + activeTarget.handleType === type && + targetHandleId === handleId; + + const glowState: "idle" | "near" | "snapped" = isActiveTarget + ? activeTarget.distancePx <= HANDLE_SNAP_RADIUS_PX + ? "snapped" + : "near" + : "idle"; + + const accentColor = canvasHandleAccentColor({ + nodeType, + handleId, + handleType: type, + }); + const glowAlpha = glowState === "snapped" ? 0.62 : glowState === "near" ? 0.4 : 0; + const ringAlpha = glowState === "snapped" ? 0.34 : glowState === "near" ? 0.2 : 0; + const glowSize = glowState === "snapped" ? 14 : glowState === "near" ? 10 : 0; + const ringSize = glowState === "snapped" ? 6 : glowState === "near" ? 4 : 0; + + return ( + + ); +} diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index e1f0a21..6c23c06 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -179,6 +179,27 @@ export function canvasHandleAccentRgb(args: { return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB; } +export function canvasHandleAccentColor(args: { + nodeType: string | undefined; + handleId?: string | null; + handleType: "source" | "target"; +}): string { + const [r, g, b] = canvasHandleAccentRgb(args); + return `rgb(${r}, ${g}, ${b})`; +} + +export function canvasHandleAccentColorWithAlpha( + args: { + nodeType: string | undefined; + handleId?: string | null; + handleType: "source" | "target"; + }, + alpha: number, +): string { + const [r, g, b] = canvasHandleAccentRgb(args); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + /** * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect). */ diff --git a/vitest.config.ts b/vitest.config.ts index 80ab732..f5af42b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ "components/canvas/__tests__/use-canvas-edge-types.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/canvas-delete-handlers.test.tsx", + "components/canvas/__tests__/canvas-handle.test.tsx", "components/canvas/__tests__/canvas-media-utils.test.ts", "components/canvas/__tests__/base-node-wrapper.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx", From db71b2485a9c7bf255ebe16d06ca13bfc3cb6673 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 08:56:45 +0200 Subject: [PATCH 4/9] refactor(canvas): unify node handles with shared wrapper --- .../canvas/__tests__/compare-node.test.tsx | 56 +++++++++++++++++++ .../canvas/__tests__/mixer-node.test.tsx | 43 +++++++++++++- components/canvas/nodes/agent-node.tsx | 11 +++- components/canvas/nodes/agent-output-node.tsx | 9 ++- components/canvas/nodes/ai-image-node.tsx | 11 +++- components/canvas/nodes/ai-video-node.tsx | 11 +++- components/canvas/nodes/asset-node.tsx | 11 +++- components/canvas/nodes/color-adjust-node.tsx | 11 +++- components/canvas/nodes/compare-node.tsx | 15 +++-- components/canvas/nodes/crop-node.tsx | 11 +++- components/canvas/nodes/curves-node.tsx | 11 +++- .../canvas/nodes/detail-adjust-node.tsx | 11 +++- components/canvas/nodes/frame-node.tsx | 11 +++- components/canvas/nodes/group-node.tsx | 11 +++- components/canvas/nodes/image-node.tsx | 11 +++- components/canvas/nodes/light-adjust-node.tsx | 11 +++- components/canvas/nodes/mixer-node.tsx | 15 +++-- components/canvas/nodes/note-node.tsx | 11 +++- components/canvas/nodes/prompt-node.tsx | 10 +++- components/canvas/nodes/render-node.tsx | 11 +++- components/canvas/nodes/text-node.tsx | 10 +++- components/canvas/nodes/video-node.tsx | 11 +++- components/canvas/nodes/video-prompt-node.tsx | 11 +++- 23 files changed, 266 insertions(+), 68 deletions(-) diff --git a/components/canvas/__tests__/compare-node.test.tsx b/components/canvas/__tests__/compare-node.test.tsx index 9e9f14b..aa60d8c 100644 --- a/components/canvas/__tests__/compare-node.test.tsx +++ b/components/canvas/__tests__/compare-node.test.tsx @@ -28,6 +28,31 @@ vi.mock("@xyflow/react", () => ({ useStore: (selector: (state: StoreState) => unknown) => selector(storeState), })); +vi.mock("@/components/canvas/canvas-handle", () => ({ + default: ({ + id, + type, + nodeId, + nodeType, + style, + }: { + id?: string; + type: "source" | "target"; + nodeId: string; + nodeType?: string; + style?: React.CSSProperties; + }) => ( +
+ ), +})); + vi.mock("../nodes/base-node-wrapper", () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); @@ -261,4 +286,35 @@ describe("CompareNode render preview inputs", () => { }, }); }); + + it("renders compare handles through CanvasHandle with preserved ids and positions", () => { + const markup = renderCompareNode({ + id: "compare-1", + data: {}, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: true, + type: "compare", + xPos: 0, + yPos: 0, + width: 500, + height: 380, + sourcePosition: undefined, + targetPosition: undefined, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }); + + expect(markup).toContain('data-canvas-handle="true"'); + expect(markup).toContain('data-node-id="compare-1"'); + expect(markup).toContain('data-node-type="compare"'); + expect(markup).toContain('data-handle-id="left"'); + expect(markup).toContain('data-handle-id="right"'); + expect(markup).toContain('data-handle-id="compare-out"'); + expect(markup).toContain('data-handle-type="target"'); + expect(markup).toContain('data-handle-type="source"'); + expect(markup).toContain('data-top="35%"'); + expect(markup).toContain('data-top="55%"'); + }); }); diff --git a/components/canvas/__tests__/mixer-node.test.tsx b/components/canvas/__tests__/mixer-node.test.tsx index d4c8e75..f0152d1 100644 --- a/components/canvas/__tests__/mixer-node.test.tsx +++ b/components/canvas/__tests__/mixer-node.test.tsx @@ -17,6 +17,31 @@ vi.mock("@xyflow/react", () => ({ Position: { Left: "left", Right: "right" }, })); +vi.mock("@/components/canvas/canvas-handle", () => ({ + default: ({ + id, + type, + nodeId, + nodeType, + style, + }: { + id?: string; + type: "source" | "target"; + nodeId: string; + nodeType?: string; + style?: React.CSSProperties; + }) => ( +
+ ), +})); + vi.mock("@/components/canvas/canvas-sync-context", () => ({ useCanvasSync: () => ({ queueNodeDataUpdate: mocks.queueNodeDataUpdate, @@ -222,8 +247,20 @@ describe("MixerNode", () => { it("renders expected mixer handles", async () => { await renderNode(); - expect(container?.querySelector('[data-handle-id="base"][data-handle-type="target"]')).toBeTruthy(); - expect(container?.querySelector('[data-handle-id="overlay"][data-handle-type="target"]')).toBeTruthy(); - expect(container?.querySelector('[data-handle-id="mixer-out"][data-handle-type="source"]')).toBeTruthy(); + expect( + container?.querySelector( + '[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="base"][data-handle-type="target"][data-top="35%"]', + ), + ).toBeTruthy(); + expect( + container?.querySelector( + '[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="overlay"][data-handle-type="target"][data-top="58%"]', + ), + ).toBeTruthy(); + expect( + container?.querySelector( + '[data-canvas-handle="true"][data-node-id="mixer-1"][data-node-type="mixer"][data-handle-id="mixer-out"][data-handle-type="source"]', + ), + ).toBeTruthy(); }); }); diff --git a/components/canvas/nodes/agent-node.tsx b/components/canvas/nodes/agent-node.tsx index ed0228f..bfe8e41 100644 --- a/components/canvas/nodes/agent-node.tsx +++ b/components/canvas/nodes/agent-node.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo, useState } from "react"; import { Bot } from "lucide-react"; -import { Handle, Position, type Node, type NodeProps } from "@xyflow/react"; +import { Position, type Node, type NodeProps } from "@xyflow/react"; import { useAction } from "convex/react"; import type { FunctionReference } from "convex/server"; import { useTranslations } from "next-intl"; @@ -33,6 +33,7 @@ import { SelectValue, } from "@/components/ui/select"; import BaseNodeWrapper from "./base-node-wrapper"; +import CanvasHandle from "@/components/canvas/canvas-handle"; type AgentNodeData = { templateId?: string; @@ -466,13 +467,17 @@ export default function AgentNode({ id, data, selected }: NodeProps - - ) { +export default function AgentOutputNode({ id, data, selected }: NodeProps) { const t = useTranslations("agentOutputNode"); const nodeData = data as AgentOutputNodeData; const isSkeleton = nodeData.isSkeleton === true; @@ -240,7 +241,9 @@ export default function AgentOutputNode({ data, selected }: NodeProps - - )} - - - - ) : null} - -
- - - - - {error}

: null}
- -
- - - - - ) selected={selected} className="min-w-[200px] min-h-[150px] p-3 border-dashed" > - ) )} - - - - - - - - ) { return ( - ) { )} - - - - - ) { ]} className="relative" > - ) { )} - - ) : null} - - - Date: Sat, 11 Apr 2026 09:04:59 +0200 Subject: [PATCH 5/9] feat(canvas): snap connection preview to magnet targets --- .../__tests__/custom-connection-line.test.tsx | 197 ++++++++++++++++++ components/canvas/custom-connection-line.tsx | 78 ++++++- vitest.config.ts | 1 + 3 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 components/canvas/__tests__/custom-connection-line.test.tsx diff --git a/components/canvas/__tests__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx new file mode 100644 index 0000000..6e8c16e --- /dev/null +++ b/components/canvas/__tests__/custom-connection-line.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + ConnectionLineType, + Position, + type ConnectionLineComponentProps, +} from "@xyflow/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CanvasConnectionMagnetismProvider, +} from "@/components/canvas/canvas-connection-magnetism-context"; +import CustomConnectionLine from "@/components/canvas/custom-connection-line"; +import { connectionLineAccentRgb } from "@/lib/canvas-utils"; + +const reactFlowStateRef: { + current: { + nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>; + edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>; + }; +} = { + current: { + nodes: [], + edges: [], + }, +}; + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + + return { + ...actual, + useReactFlow: () => ({ + getNodes: () => reactFlowStateRef.current.nodes, + getEdges: () => reactFlowStateRef.current.edges, + }), + }; +}); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const baseProps = { + connectionLineType: ConnectionLineType.Straight, + fromNode: { + id: "source-node", + type: "image", + }, + fromHandle: { + id: "image-out", + type: "source", + nodeId: "source-node", + position: Position.Right, + x: 0, + y: 0, + width: 12, + height: 12, + }, + fromX: 20, + fromY: 40, + toX: 290, + toY: 210, + fromPosition: Position.Right, + toPosition: Position.Left, + connectionStatus: "valid", +} as unknown as ConnectionLineComponentProps; + +describe("CustomConnectionLine", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + document + .querySelectorAll("[data-testid='custom-line-magnet-handle']") + .forEach((element) => element.remove()); + container = null; + root = null; + }); + + function renderLine(args?: { + withMagnetHandle?: boolean; + connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; + }) { + document + .querySelectorAll("[data-testid='custom-line-magnet-handle']") + .forEach((element) => element.remove()); + + reactFlowStateRef.current = { + nodes: [ + { id: "source-node", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} }, + ], + edges: [], + }; + + if (args?.withMagnetHandle && container) { + const handleEl = document.createElement("div"); + handleEl.setAttribute("data-testid", "custom-line-magnet-handle"); + handleEl.setAttribute("data-node-id", "target-node"); + handleEl.setAttribute("data-handle-id", ""); + handleEl.setAttribute("data-handle-type", "target"); + handleEl.getBoundingClientRect = () => + ({ + x: 294, + y: 214, + top: 214, + left: 294, + right: 306, + bottom: 226, + width: 12, + height: 12, + toJSON: () => ({}), + }) as DOMRect; + document.body.appendChild(handleEl); + } + + act(() => { + root?.render( + + + + + , + ); + }); + } + + function getPath() { + const path = container?.querySelector("path"); + if (!(path instanceof Element) || path.tagName.toLowerCase() !== "path") { + throw new Error("Connection line path not rendered"); + } + return path as SVGElement; + } + + it("renders with the existing accent color when no magnet target is active", () => { + renderLine(); + + const [r, g, b] = connectionLineAccentRgb("image", "image-out"); + const path = getPath(); + + expect(path.style.stroke).toBe(`rgb(${r}, ${g}, ${b})`); + expect(path.getAttribute("d")).toContain("290"); + expect(path.getAttribute("d")).toContain("210"); + }); + + it("snaps endpoint to active magnet target center", () => { + renderLine({ + withMagnetHandle: true, + }); + + const path = getPath(); + expect(path.getAttribute("d")).toContain("300"); + expect(path.getAttribute("d")).toContain("220"); + }); + + it("strengthens stroke visual feedback while snapped", () => { + renderLine(); + const idlePath = getPath(); + const idleStrokeWidth = idlePath.style.strokeWidth; + const idleFilter = idlePath.style.filter; + + renderLine({ + withMagnetHandle: true, + }); + const snappedPath = getPath(); + + expect(snappedPath.style.strokeWidth).not.toBe(idleStrokeWidth); + expect(snappedPath.style.filter).not.toBe(idleFilter); + }); + + it("keeps invalid connection opacity behavior while snapped", () => { + renderLine({ + withMagnetHandle: true, + connectionStatus: "invalid", + }); + + const path = getPath(); + expect(path.style.opacity).toBe("0.45"); + }); +}); diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index ca049a4..2824177 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -7,9 +7,38 @@ import { getSmoothStepPath, getStraightPath, type ConnectionLineComponentProps, + useReactFlow, } from "@xyflow/react"; +import { useEffect, useMemo } from "react"; + +import { + HANDLE_SNAP_RADIUS_PX, + resolveCanvasMagnetTarget, +} from "@/components/canvas/canvas-connection-magnetism"; +import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; import { connectionLineAccentRgb } from "@/lib/canvas-utils"; +function hasSameMagnetTarget( + a: Parameters["setActiveTarget"]>[0], + b: Parameters["setActiveTarget"]>[0], +): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + + return ( + a.nodeId === b.nodeId && + a.handleId === b.handleId && + a.handleType === b.handleType && + a.centerX === b.centerX && + a.centerY === b.centerY && + a.distancePx === b.distancePx + ); +} + export default function CustomConnectionLine({ connectionLineType, fromNode, @@ -22,12 +51,50 @@ export default function CustomConnectionLine({ toPosition, connectionStatus, }: ConnectionLineComponentProps) { + const { getNodes, getEdges } = useReactFlow(); + const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); + + const fromHandleType = + fromHandle?.type === "source" || fromHandle?.type === "target" + ? fromHandle.type + : null; + + const resolvedMagnetTarget = useMemo(() => { + if (!fromHandleType || !fromNode?.id) { + return null; + } + + return resolveCanvasMagnetTarget({ + point: { x: toX, y: toY }, + fromNodeId: fromNode.id, + fromHandleId: fromHandle?.id ?? undefined, + fromHandleType, + nodes: getNodes(), + edges: getEdges(), + }); + }, [fromHandle?.id, fromHandleType, fromNode?.id, getEdges, getNodes, toX, toY]); + + useEffect(() => { + if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) { + return; + } + setActiveTarget(resolvedMagnetTarget); + }, [activeTarget, resolvedMagnetTarget, setActiveTarget]); + + const magnetTarget = activeTarget ?? resolvedMagnetTarget; + const snappedTarget = + magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX + ? magnetTarget + : null; + const targetX = snappedTarget?.centerX ?? toX; + const targetY = snappedTarget?.centerY ?? toY; + const pathParams = { sourceX: fromX, sourceY: fromY, sourcePosition: fromPosition, - targetX: toX, - targetY: toY, + targetX, + targetY, targetPosition: toPosition, }; @@ -54,6 +121,10 @@ export default function CustomConnectionLine({ const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id); const opacity = connectionStatus === "invalid" ? 0.45 : 1; + const strokeWidth = snappedTarget ? 3.25 : 2.5; + const filter = snappedTarget + ? `drop-shadow(0 0 3px rgba(${r}, ${g}, ${b}, 0.7)) drop-shadow(0 0 8px rgba(${r}, ${g}, ${b}, 0.48))` + : undefined; return ( diff --git a/vitest.config.ts b/vitest.config.ts index f5af42b..78563a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/canvas-delete-handlers.test.tsx", "components/canvas/__tests__/canvas-handle.test.tsx", + "components/canvas/__tests__/custom-connection-line.test.tsx", "components/canvas/__tests__/canvas-media-utils.test.ts", "components/canvas/__tests__/base-node-wrapper.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx", From ab491eb1419a76d181ae974abe81c163156117ae Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 09:12:53 +0200 Subject: [PATCH 6/9] fix(canvas): align magnetism tests and connection-line lint --- .../canvas/__tests__/use-node-local-data.test.tsx | 1 + .../canvas/canvas-connection-magnetism-context.tsx | 12 ++++++------ components/canvas/custom-connection-line.tsx | 12 +++++++----- tests/agent-node-runtime.test.ts | 1 + tests/agent-node.test.ts | 1 + tests/agent-output-node.test.ts | 1 + tests/ai-video-node.test.ts | 1 + tests/crop-node.test.ts | 1 + tests/light-adjust-node.test.ts | 1 + tests/prompt-node.test.ts | 1 + tests/use-pipeline-preview.test.ts | 9 +++++++++ tests/video-prompt-node.test.ts | 1 + 12 files changed, 31 insertions(+), 11 deletions(-) diff --git a/components/canvas/__tests__/use-node-local-data.test.tsx b/components/canvas/__tests__/use-node-local-data.test.tsx index ed2c6cb..4a30b0b 100644 --- a/components/canvas/__tests__/use-node-local-data.test.tsx +++ b/components/canvas/__tests__/use-node-local-data.test.tsx @@ -699,6 +699,7 @@ describe("favorite retention in strict local node flows", () => { vi.doMock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); const importedModule = (await import(modulePath)) as { diff --git a/components/canvas/canvas-connection-magnetism-context.tsx b/components/canvas/canvas-connection-magnetism-context.tsx index e0b85aa..b2a5504 100644 --- a/components/canvas/canvas-connection-magnetism-context.tsx +++ b/components/canvas/canvas-connection-magnetism-context.tsx @@ -18,6 +18,11 @@ type CanvasConnectionMagnetismState = { const CanvasConnectionMagnetismContext = createContext(null); +const FALLBACK_MAGNETISM_STATE: CanvasConnectionMagnetismState = { + activeTarget: null, + setActiveTarget: () => undefined, +}; + export function CanvasConnectionMagnetismProvider({ children, }: { @@ -42,10 +47,5 @@ export function CanvasConnectionMagnetismProvider({ export function useCanvasConnectionMagnetism(): CanvasConnectionMagnetismState { const context = useContext(CanvasConnectionMagnetismContext); - if (!context) { - throw new Error( - "useCanvasConnectionMagnetism must be used within CanvasConnectionMagnetismProvider", - ); - } - return context; + return context ?? FALLBACK_MAGNETISM_STATE; } diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index 2824177..51bcbe7 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -53,6 +53,8 @@ export default function CustomConnectionLine({ }: ConnectionLineComponentProps) { const { getNodes, getEdges } = useReactFlow(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); + const fromHandleId = fromHandle?.id; + const fromNodeId = fromNode?.id; const fromHandleType = fromHandle?.type === "source" || fromHandle?.type === "target" @@ -60,19 +62,19 @@ export default function CustomConnectionLine({ : null; const resolvedMagnetTarget = useMemo(() => { - if (!fromHandleType || !fromNode?.id) { + if (!fromHandleType || !fromNodeId) { return null; } return resolveCanvasMagnetTarget({ point: { x: toX, y: toY }, - fromNodeId: fromNode.id, - fromHandleId: fromHandle?.id ?? undefined, + fromNodeId, + fromHandleId: fromHandleId ?? undefined, fromHandleType, nodes: getNodes(), edges: getEdges(), }); - }, [fromHandle?.id, fromHandleType, fromNode?.id, getEdges, getNodes, toX, toY]); + }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, toX, toY]); useEffect(() => { if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) { @@ -119,7 +121,7 @@ export default function CustomConnectionLine({ [path] = getStraightPath(pathParams); } - const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id); + const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId); const opacity = connectionStatus === "invalid" ? 0.45 : 1; const strokeWidth = snappedTarget ? 3.25 : 2.5; const filter = snappedTarget diff --git a/tests/agent-node-runtime.test.ts b/tests/agent-node-runtime.test.ts index b630b54..bba58cc 100644 --- a/tests/agent-node-runtime.test.ts +++ b/tests/agent-node-runtime.test.ts @@ -142,6 +142,7 @@ vi.mock("next-intl", () => ({ vi.mock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); import AgentNode from "@/components/canvas/nodes/agent-node"; diff --git a/tests/agent-node.test.ts b/tests/agent-node.test.ts index 197f2f4..f9cb350 100644 --- a/tests/agent-node.test.ts +++ b/tests/agent-node.test.ts @@ -60,6 +60,7 @@ vi.mock("@xyflow/react", () => ({ }); }, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); const translations: Record = { diff --git a/tests/agent-output-node.test.ts b/tests/agent-output-node.test.ts index 54cc19b..4bd5d9f 100644 --- a/tests/agent-output-node.test.ts +++ b/tests/agent-output-node.test.ts @@ -20,6 +20,7 @@ vi.mock("@xyflow/react", () => ({ }); }, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); const translations: Record = { diff --git a/tests/ai-video-node.test.ts b/tests/ai-video-node.test.ts index d27084f..e4cd82d 100644 --- a/tests/ai-video-node.test.ts +++ b/tests/ai-video-node.test.ts @@ -54,6 +54,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ vi.mock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), useReactFlow: () => ({ getEdges: mocks.getEdges, getNode: mocks.getNode, diff --git a/tests/crop-node.test.ts b/tests/crop-node.test.ts index 7172230..2a797ab 100644 --- a/tests/crop-node.test.ts +++ b/tests/crop-node.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); vi.mock("next-intl", () => ({ diff --git a/tests/light-adjust-node.test.ts b/tests/light-adjust-node.test.ts index 7a10e8e..dfb9cd5 100644 --- a/tests/light-adjust-node.test.ts +++ b/tests/light-adjust-node.test.ts @@ -19,6 +19,7 @@ const parameterSliderState = vi.hoisted(() => ({ vi.mock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); vi.mock("convex/react", () => ({ diff --git a/tests/prompt-node.test.ts b/tests/prompt-node.test.ts index 1e1e469..1c0dc51 100644 --- a/tests/prompt-node.test.ts +++ b/tests/prompt-node.test.ts @@ -135,6 +135,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ vi.mock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) => selector({ edges: mocks.edges, nodes: mocks.nodes }), useReactFlow: () => ({ diff --git a/tests/use-pipeline-preview.test.ts b/tests/use-pipeline-preview.test.ts index 6545edc..ae01755 100644 --- a/tests/use-pipeline-preview.test.ts +++ b/tests/use-pipeline-preview.test.ts @@ -691,6 +691,7 @@ describe("preview histogram call sites", () => { vi.doMock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); vi.doMock("convex/react", () => ({ useMutation: () => vi.fn(async () => undefined), @@ -754,6 +755,8 @@ describe("preview histogram call sites", () => { })); vi.doMock("@/lib/canvas-utils", () => ({ resolveMediaAspectRatio: () => null, + canvasHandleAccentColor: () => "rgb(13, 148, 136)", + canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)", })); vi.doMock("@/lib/image-formats", () => ({ parseAspectRatioString: () => ({ w: 1, h: 1 }), @@ -875,6 +878,7 @@ describe("preview histogram call sites", () => { vi.doMock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); vi.doMock("convex/react", () => ({ useMutation: () => vi.fn(async () => undefined), @@ -935,6 +939,8 @@ describe("preview histogram call sites", () => { })); vi.doMock("@/lib/canvas-utils", () => ({ resolveMediaAspectRatio: () => null, + canvasHandleAccentColor: () => "rgb(13, 148, 136)", + canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)", })); vi.doMock("@/lib/image-formats", () => ({ parseAspectRatioString: () => ({ w: 1, h: 1 }), @@ -1063,6 +1069,7 @@ describe("preview histogram call sites", () => { vi.doMock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), })); vi.doMock("convex/react", () => ({ useMutation: () => vi.fn(async () => undefined), @@ -1126,6 +1133,8 @@ describe("preview histogram call sites", () => { })); vi.doMock("@/lib/canvas-utils", () => ({ resolveMediaAspectRatio: () => null, + canvasHandleAccentColor: () => "rgb(13, 148, 136)", + canvasHandleAccentColorWithAlpha: () => "rgba(13, 148, 136, 0.4)", })); vi.doMock("@/lib/image-formats", () => ({ parseAspectRatioString: () => ({ w: 1, h: 1 }), diff --git a/tests/video-prompt-node.test.ts b/tests/video-prompt-node.test.ts index 2cdc28d..1bf88ea 100644 --- a/tests/video-prompt-node.test.ts +++ b/tests/video-prompt-node.test.ts @@ -123,6 +123,7 @@ vi.mock("@/components/canvas/nodes/base-node-wrapper", () => ({ vi.mock("@xyflow/react", () => ({ Handle: () => null, Position: { Left: "left", Right: "right" }, + useConnection: () => ({ inProgress: false }), useStore: (selector: (state: { edges: typeof mocks.edges; nodes: typeof mocks.nodes }) => unknown) => selector({ edges: mocks.edges, nodes: mocks.nodes }), useReactFlow: () => ({ From e33e032cfcd7c626428f645346bf007c13860968 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 09:12:58 +0200 Subject: [PATCH 7/9] docs(canvas): document connection magnetism layer --- components/canvas/CLAUDE.md | 11 +++++++++++ lib/CLAUDE.md | 1 + 2 files changed, 12 insertions(+) diff --git a/components/canvas/CLAUDE.md b/components/canvas/CLAUDE.md index e9abc1e..f944529 100644 --- a/components/canvas/CLAUDE.md +++ b/components/canvas/CLAUDE.md @@ -35,10 +35,21 @@ app/(app)/canvas/[canvasId]/page.tsx | `canvas-scissors.ts` | Hook für Scherenmodus (K/Esc Toggle, Click-Cut, Stroke-Cut) | | `canvas-delete-handlers.ts` | Hook für `onBeforeDelete`, `onNodesDelete`, `onEdgesDelete` inkl. Bridge-Edges | | `canvas-reconnect.ts` | Hook für Edge-Reconnect (`onReconnectStart`, `onReconnect`, `onReconnectEnd`) | +| `canvas-connection-magnetism.ts` | Pure Magnet-Resolver für Handle-Proximity (`resolveCanvasMagnetTarget`) inkl. Glow/Snap-Radien | +| `canvas-connection-magnetism-context.tsx` | Transienter Client-State für aktives Magnet-Target während Connect/Reconnect-Drags | | `canvas-media-utils.ts` | Media-Helfer wie `getImageDimensions(file)` | | `use-canvas-data.ts` | Hook: Bündelt Canvas-Graph-Query, Storage-URL-Auflösung und Auth-State in einer einzigen Abstraktion | | `canvas-graph-query-cache.ts` | Optimistic Store Helper für `canvasGraph.get` (getNodes, getEdges, setNodes, setEdges) | +### Connection Magnetism (client-only) + +- Magnetism ist eine rein clientseitige UX-Schicht über dem bestehenden React-Flow-Connect-Flow; Persistenz, Edge-Schema und Convex-Mutations bleiben unverändert. +- `HANDLE_GLOW_RADIUS_PX = 56` und `HANDLE_SNAP_RADIUS_PX = 40` liegen zentral in `canvas-connection-magnetism.ts` und werden von Resolver, Handle-Glow und Connection-Line gemeinsam genutzt. +- `resolveCanvasMagnetTarget(...)` sucht LemonSpace-eigene Handle-DOM-Kandidaten über `data-node-id` / `data-handle-id` / `data-handle-type`, berechnet die Distanz zum Pointer und wählt stabil das nächste gültige Handle. +- `CanvasConnectionMagnetismProvider` (in `canvas.tsx`) stellt `activeTarget` und `setActiveTarget` für `CustomConnectionLine`, `CanvasHandle` und Connect/Reconnect-Hooks bereit; der State ist transient und wird nach Drag-Ende geleert. +- `CanvasHandle` ist der gemeinsame Wrapper für alle Node-Handles (statt direktes `` pro Node), rendert `idle|near|snapped` Glow-States und exportiert stabile `data-*` Attribute für die Geometrie-Lookups. +- Connectability bleibt strikt policy-getrieben: Magnet-Targets werden nur akzeptiert, wenn `validateCanvasConnectionPolicy(...)` bzw. die bestehende Validierungslogik die Verbindung erlaubt. + --- ## Node-Taxonomie (Phase 1) diff --git a/lib/CLAUDE.md b/lib/CLAUDE.md index 0c9f6ad..81b3f9c 100644 --- a/lib/CLAUDE.md +++ b/lib/CLAUDE.md @@ -68,6 +68,7 @@ Alle Adapter-Funktionen zwischen Convex-Datenmodell und React Flow. Details in ` - `NODE_DEFAULTS` — Default-Größen und Daten per Node-Typ (inkl. `video-prompt` und `ai-video`) - `NODE_HANDLE_MAP` — Handle-IDs pro Node-Typ (inkl. `video-prompt-out/in` und `video-out/in`) - `SOURCE_NODE_GLOW_RGB` — Edge-Glow-Farben pro Source-Node-Typ (inkl. `video-prompt` und `ai-video`) +- `canvasHandleAccentRgb`, `canvasHandleAccentColor`, `canvasHandleAccentColorWithAlpha` — gemeinsame Handle-Akzentfarben (inkl. Spezialfälle für `compare.left/right/compare-out` und `mixer.base/overlay/mixer-out`) - `agent` ist als input-only Node enthalten (`NODE_HANDLE_MAP.agent = { target: "agent-in" }`) - `computeBridgeCreatesForDeletedNodes` — Kanten-Reconnect nach Node-Löschung - `computeMediaNodeSize` — Dynamische Node-Größe basierend auf Bild-Dimensionen From 079bc34ce43582d06001ba1641123e682a25a3f8 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 09:20:39 +0200 Subject: [PATCH 8/9] fix(canvas): restore visible handle glow during drag --- .../canvas/__tests__/canvas-handle.test.tsx | 48 +++++++++++++++---- .../__tests__/custom-connection-line.test.tsx | 37 +++++++++++++- components/canvas/canvas-handle.tsx | 18 ++++++- components/canvas/custom-connection-line.tsx | 9 +++- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/components/canvas/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx index 972c47d..62c8c3a 100644 --- a/components/canvas/__tests__/canvas-handle.test.tsx +++ b/components/canvas/__tests__/canvas-handle.test.tsx @@ -10,7 +10,13 @@ import { useCanvasConnectionMagnetism, } from "@/components/canvas/canvas-connection-magnetism-context"; -const connectionStateRef: { current: { inProgress: boolean } } = { +const connectionStateRef: { + current: { + inProgress?: boolean; + fromNode?: { id: string }; + fromHandle?: { id?: string; type?: "source" | "target" }; + }; +} = { current: { inProgress: false }, }; @@ -77,7 +83,11 @@ describe("CanvasHandle", () => { }); async function renderHandle(args?: { - inProgress?: boolean; + connectionState?: { + inProgress?: boolean; + fromNode?: { id: string }; + fromHandle?: { id?: string; type?: "source" | "target" }; + }; activeTarget?: { nodeId: string; handleId?: string; @@ -88,7 +98,7 @@ describe("CanvasHandle", () => { } | null; props?: Partial>; }) { - connectionStateRef.current = { inProgress: args?.inProgress ?? false }; + connectionStateRef.current = args?.connectionState ?? { inProgress: false }; await act(async () => { root?.render( @@ -98,7 +108,7 @@ describe("CanvasHandle", () => { nodeId="node-1" nodeType="image" type="target" - position="left" + position={"left" as React.ComponentProps["position"]} id="image-in" {...args?.props} /> @@ -128,7 +138,7 @@ describe("CanvasHandle", () => { it("turns on near-target glow when this handle is active target", async () => { await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "node-1", handleId: "image-in", @@ -145,7 +155,7 @@ describe("CanvasHandle", () => { it("renders a stronger glow in snapped state than near state", async () => { await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "node-1", handleId: "image-in", @@ -160,7 +170,7 @@ describe("CanvasHandle", () => { const nearGlow = nearHandle.style.boxShadow; await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "node-1", handleId: "image-in", @@ -178,7 +188,7 @@ describe("CanvasHandle", () => { it("does not glow for non-target handles during the same drag", async () => { await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "other-node", handleId: "image-in", @@ -193,13 +203,33 @@ describe("CanvasHandle", () => { expect(handle.getAttribute("data-glow-state")).toBe("idle"); }); + it("shows glow while dragging when connection payload exists without inProgress", async () => { + await renderHandle({ + connectionState: { + fromNode: { id: "source-node" }, + fromHandle: { id: "image-out", type: "source" }, + }, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX + 2, + }, + }); + + const handle = getHandleElement(); + expect(handle.getAttribute("data-glow-state")).toBe("near"); + }); + it("emits stable handle geometry data attributes", async () => { await renderHandle({ props: { nodeId: "node-2", id: undefined, type: "source", - position: "right", + position: "right" as React.ComponentProps["position"], }, }); diff --git a/components/canvas/__tests__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx index 6e8c16e..379b438 100644 --- a/components/canvas/__tests__/custom-connection-line.test.tsx +++ b/components/canvas/__tests__/custom-connection-line.test.tsx @@ -27,6 +27,16 @@ const reactFlowStateRef: { }, }; +const connectionStateRef: { + current: { + fromHandle?: { type?: "source" | "target" }; + }; +} = { + current: { + fromHandle: { type: "source" }, + }, +}; + vi.mock("@xyflow/react", async () => { const actual = await vi.importActual("@xyflow/react"); @@ -36,6 +46,7 @@ vi.mock("@xyflow/react", async () => { getNodes: () => reactFlowStateRef.current.nodes, getEdges: () => reactFlowStateRef.current.edges, }), + useConnection: () => connectionStateRef.current, }; }); @@ -93,6 +104,7 @@ describe("CustomConnectionLine", () => { function renderLine(args?: { withMagnetHandle?: boolean; connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; + omitFromHandleType?: boolean; }) { document .querySelectorAll("[data-testid='custom-line-magnet-handle']") @@ -106,6 +118,10 @@ describe("CustomConnectionLine", () => { edges: [], }; + connectionStateRef.current = { + fromHandle: { type: "source" }, + }; + if (args?.withMagnetHandle && container) { const handleEl = document.createElement("div"); handleEl.setAttribute("data-testid", "custom-line-magnet-handle"); @@ -128,11 +144,19 @@ describe("CustomConnectionLine", () => { } act(() => { + const lineProps = { + ...baseProps, + fromHandle: { + ...baseProps.fromHandle, + ...(args?.omitFromHandleType ? { type: undefined } : null), + }, + } as ConnectionLineComponentProps; + root?.render( @@ -170,6 +194,17 @@ describe("CustomConnectionLine", () => { expect(path.getAttribute("d")).toContain("220"); }); + it("still resolves magnet target when fromHandle.type is missing", () => { + renderLine({ + withMagnetHandle: true, + omitFromHandleType: true, + }); + + const path = getPath(); + expect(path.getAttribute("d")).toContain("300"); + expect(path.getAttribute("d")).toContain("220"); + }); + it("strengthens stroke visual feedback while snapped", () => { renderLine(); const idlePath = getPath(); diff --git a/components/canvas/canvas-handle.tsx b/components/canvas/canvas-handle.tsx index 82265a5..bc03931 100644 --- a/components/canvas/canvas-handle.tsx +++ b/components/canvas/canvas-handle.tsx @@ -34,10 +34,26 @@ export default function CanvasHandle({ const connection = useConnection(); const { activeTarget } = useCanvasConnectionMagnetism(); + const connectionState = connection as { + inProgress?: boolean; + fromNode?: unknown; + toNode?: unknown; + fromHandle?: unknown; + toHandle?: unknown; + }; + const hasConnectionPayload = + connectionState.fromNode !== undefined || + connectionState.toNode !== undefined || + connectionState.fromHandle !== undefined || + connectionState.toHandle !== undefined; + const isConnectionDragActive = + connectionState.inProgress === true || + (connectionState.inProgress === undefined && hasConnectionPayload); + const handleId = normalizeHandleId(id); const targetHandleId = normalizeHandleId(activeTarget?.handleId); const isActiveTarget = - connection.inProgress && + isConnectionDragActive && activeTarget !== null && activeTarget.nodeId === nodeId && activeTarget.handleType === type && diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index 51bcbe7..8dbf683 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -7,6 +7,7 @@ import { getSmoothStepPath, getStraightPath, type ConnectionLineComponentProps, + useConnection, useReactFlow, } from "@xyflow/react"; import { useEffect, useMemo } from "react"; @@ -52,14 +53,20 @@ export default function CustomConnectionLine({ connectionStatus, }: ConnectionLineComponentProps) { const { getNodes, getEdges } = useReactFlow(); + const connection = useConnection(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const fromHandleId = fromHandle?.id; const fromNodeId = fromNode?.id; + const connectionFromHandleType = + connection.fromHandle?.type === "source" || connection.fromHandle?.type === "target" + ? connection.fromHandle.type + : null; + const fromHandleType = fromHandle?.type === "source" || fromHandle?.type === "target" ? fromHandle.type - : null; + : connectionFromHandleType ?? "source"; const resolvedMagnetTarget = useMemo(() => { if (!fromHandleType || !fromNodeId) { From 22d0187c66fb2f9b42a6027c1f5b13e5f5f31d3e Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 10:46:43 +0200 Subject: [PATCH 9/9] fix(canvas): strengthen pre-snap glow and reconnect drag UX --- app/globals.css | 5 + .../canvas/__tests__/canvas-handle.test.tsx | 103 +++++++++++++++++- .../__tests__/custom-connection-line.test.tsx | 88 ++++++++++++++- .../canvas/canvas-connection-magnetism.ts | 46 ++++++++ components/canvas/canvas-handle.tsx | 75 ++++++++++--- components/canvas/canvas.tsx | 4 + components/canvas/custom-connection-line.tsx | 47 ++++++-- lib/canvas-utils.ts | 78 +++++++++++++ 8 files changed, 415 insertions(+), 31 deletions(-) diff --git a/app/globals.css b/app/globals.css index f2735b5..48156a5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -190,6 +190,11 @@ z-index: 50; } + /* Reconnect-Anker immer pointer-interactive halten (Drag-Detach/Reconnect) */ + .react-flow__edgeupdater { + pointer-events: all; + } + /* Proximity-Vorschaukante (temp) */ .react-flow__edge.temp { opacity: 0.9; diff --git a/components/canvas/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx index 62c8c3a..da8033b 100644 --- a/components/canvas/__tests__/canvas-handle.test.tsx +++ b/components/canvas/__tests__/canvas-handle.test.tsx @@ -4,7 +4,10 @@ import React, { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { HANDLE_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism"; +import { + HANDLE_GLOW_RADIUS_PX, + HANDLE_SNAP_RADIUS_PX, +} from "@/components/canvas/canvas-connection-magnetism"; import { CanvasConnectionMagnetismProvider, useCanvasConnectionMagnetism, @@ -15,6 +18,9 @@ const connectionStateRef: { inProgress?: boolean; fromNode?: { id: string }; fromHandle?: { id?: string; type?: "source" | "target" }; + toNode?: { id: string } | null; + toHandle?: { id?: string | null; type?: "source" | "target" } | null; + isValid?: boolean | null; }; } = { current: { inProgress: false }, @@ -78,6 +84,7 @@ describe("CanvasHandle", () => { }); } container?.remove(); + document.documentElement.classList.remove("dark"); container = null; root = null; }); @@ -87,6 +94,9 @@ describe("CanvasHandle", () => { inProgress?: boolean; fromNode?: { id: string }; fromHandle?: { id?: string; type?: "source" | "target" }; + toNode?: { id: string } | null; + toHandle?: { id?: string | null; type?: "source" | "target" } | null; + isValid?: boolean | null; }; activeTarget?: { nodeId: string; @@ -186,6 +196,42 @@ describe("CanvasHandle", () => { expect(snappedHandle.style.boxShadow).not.toBe(nearGlow); }); + it("ramps up glow intensity as pointer gets closer within glow radius", async () => { + await renderHandle({ + connectionState: { inProgress: true }, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_GLOW_RADIUS_PX - 1, + }, + }); + + const farHandle = getHandleElement(); + const farStrength = Number(farHandle.getAttribute("data-glow-strength") ?? "0"); + + await renderHandle({ + connectionState: { inProgress: true }, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX + 1, + }, + }); + + const nearHandle = getHandleElement(); + const nearStrength = Number(nearHandle.getAttribute("data-glow-strength") ?? "0"); + + expect(farHandle.getAttribute("data-glow-state")).toBe("near"); + expect(nearHandle.getAttribute("data-glow-state")).toBe("near"); + expect(nearStrength).toBeGreaterThan(farStrength); + }); + it("does not glow for non-target handles during the same drag", async () => { await renderHandle({ connectionState: { inProgress: true }, @@ -223,6 +269,61 @@ describe("CanvasHandle", () => { expect(handle.getAttribute("data-glow-state")).toBe("near"); }); + it("shows glow from native connection hover target even without custom magnet target", async () => { + await renderHandle({ + connectionState: { + inProgress: true, + isValid: true, + toNode: { id: "node-1" }, + toHandle: { id: "image-in", type: "target" }, + }, + activeTarget: null, + }); + + const handle = getHandleElement(); + expect(handle.getAttribute("data-glow-state")).toBe("snapped"); + }); + + it("adapts glow rendering between light and dark modes", async () => { + await renderHandle({ + connectionState: { inProgress: true }, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX + 1, + }, + }); + + const lightHandle = getHandleElement(); + const lightShadow = lightHandle.style.boxShadow; + const lightMode = lightHandle.getAttribute("data-glow-mode"); + + document.documentElement.classList.add("dark"); + + await renderHandle({ + connectionState: { inProgress: true }, + activeTarget: { + nodeId: "node-1", + handleId: "image-in", + handleType: "target", + centerX: 120, + centerY: 80, + distancePx: HANDLE_SNAP_RADIUS_PX + 1, + }, + }); + + const darkHandle = getHandleElement(); + const darkShadow = darkHandle.style.boxShadow; + const darkMode = darkHandle.getAttribute("data-glow-mode"); + + expect(lightMode).toBe("light"); + expect(darkMode).toBe("dark"); + expect(darkShadow).not.toBe(lightShadow); + }); + it("emits stable handle geometry data attributes", async () => { await renderHandle({ props: { diff --git a/components/canvas/__tests__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx index 379b438..bb583ce 100644 --- a/components/canvas/__tests__/custom-connection-line.test.tsx +++ b/components/canvas/__tests__/custom-connection-line.test.tsx @@ -19,11 +19,13 @@ const reactFlowStateRef: { current: { nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>; edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>; + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => { x: number; y: number }; }; } = { current: { nodes: [], edges: [], + screenToFlowPosition: ({ x, y }) => ({ x, y }), }, }; @@ -45,6 +47,7 @@ vi.mock("@xyflow/react", async () => { useReactFlow: () => ({ getNodes: () => reactFlowStateRef.current.nodes, getEdges: () => reactFlowStateRef.current.edges, + screenToFlowPosition: reactFlowStateRef.current.screenToFlowPosition, }), useConnection: () => connectionStateRef.current, }; @@ -97,6 +100,7 @@ describe("CustomConnectionLine", () => { document .querySelectorAll("[data-testid='custom-line-magnet-handle']") .forEach((element) => element.remove()); + document.documentElement.classList.remove("dark"); container = null; root = null; }); @@ -105,6 +109,9 @@ describe("CustomConnectionLine", () => { withMagnetHandle?: boolean; connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; omitFromHandleType?: boolean; + toX?: number; + toY?: number; + pointer?: { x: number; y: number }; }) { document .querySelectorAll("[data-testid='custom-line-magnet-handle']") @@ -116,6 +123,7 @@ describe("CustomConnectionLine", () => { { id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} }, ], edges: [], + screenToFlowPosition: ({ x, y }) => ({ x, y }), }; connectionStateRef.current = { @@ -144,11 +152,14 @@ describe("CustomConnectionLine", () => { } act(() => { - const lineProps = { - ...baseProps, - fromHandle: { - ...baseProps.fromHandle, - ...(args?.omitFromHandleType ? { type: undefined } : null), + const lineProps = { + ...baseProps, + ...(args?.toX !== undefined ? { toX: args.toX } : null), + ...(args?.toY !== undefined ? { toY: args.toY } : null), + ...(args?.pointer ? { pointer: args.pointer } : null), + fromHandle: { + ...baseProps.fromHandle, + ...(args?.omitFromHandleType ? { type: undefined } : null), }, } as ConnectionLineComponentProps; @@ -220,6 +231,29 @@ describe("CustomConnectionLine", () => { expect(snappedPath.style.filter).not.toBe(idleFilter); }); + it("ramps stroke feedback up as pointer gets closer before snap", () => { + renderLine({ + withMagnetHandle: true, + toX: 252, + toY: 220, + pointer: { x: 252, y: 220 }, + }); + const farNearPath = getPath(); + const farNearWidth = Number(farNearPath.style.strokeWidth || "0"); + + renderLine({ + withMagnetHandle: true, + toX: 266, + toY: 220, + pointer: { x: 266, y: 220 }, + }); + const closeNearPath = getPath(); + const closeNearWidth = Number(closeNearPath.style.strokeWidth || "0"); + + expect(farNearWidth).toBeGreaterThan(2.5); + expect(closeNearWidth).toBeGreaterThan(farNearWidth); + }); + it("keeps invalid connection opacity behavior while snapped", () => { renderLine({ withMagnetHandle: true, @@ -229,4 +263,48 @@ describe("CustomConnectionLine", () => { const path = getPath(); expect(path.style.opacity).toBe("0.45"); }); + + it("uses client pointer coordinates for magnet lookup and converts snapped endpoint back to flow space", () => { + reactFlowStateRef.current.screenToFlowPosition = ({ x, y }) => ({ + x: Math.round(x / 10), + y: Math.round(y / 10), + }); + + renderLine({ + withMagnetHandle: true, + toX: 29, + toY: 21, + pointer: { x: 300, y: 220 }, + }); + + const path = getPath(); + expect(path.getAttribute("d")).toContain("30"); + expect(path.getAttribute("d")).toContain("22"); + }); + + it("adjusts glow filter between light and dark mode", () => { + renderLine({ + withMagnetHandle: true, + toX: 266, + toY: 220, + pointer: { x: 266, y: 220 }, + }); + const lightPath = getPath(); + const lightFilter = lightPath.style.filter; + + document.documentElement.classList.add("dark"); + + renderLine({ + withMagnetHandle: true, + toX: 266, + toY: 220, + pointer: { x: 266, y: 220 }, + }); + const darkPath = getPath(); + const darkFilter = darkPath.style.filter; + + expect(lightFilter).not.toBe(""); + expect(darkFilter).not.toBe(""); + expect(darkFilter).not.toBe(lightFilter); + }); }); diff --git a/components/canvas/canvas-connection-magnetism.ts b/components/canvas/canvas-connection-magnetism.ts index 468049b..949b3f7 100644 --- a/components/canvas/canvas-connection-magnetism.ts +++ b/components/canvas/canvas-connection-magnetism.ts @@ -5,6 +5,52 @@ import { validateCanvasConnectionPolicy } from "@/lib/canvas-connection-policy"; export const HANDLE_GLOW_RADIUS_PX = 56; export const HANDLE_SNAP_RADIUS_PX = 40; +function clamp01(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + if (value <= 0) { + return 0; + } + if (value >= 1) { + return 1; + } + return value; +} + +function smoothstep(value: number): number { + const v = clamp01(value); + return v * v * (3 - 2 * v); +} + +export function resolveCanvasGlowStrength(args: { + distancePx: number; + glowRadiusPx?: number; + snapRadiusPx?: number; +}): number { + const glowRadius = args.glowRadiusPx ?? HANDLE_GLOW_RADIUS_PX; + const snapRadius = args.snapRadiusPx ?? HANDLE_SNAP_RADIUS_PX; + + if (!Number.isFinite(args.distancePx)) { + return 0; + } + if (args.distancePx <= 0) { + return 1; + } + if (args.distancePx >= glowRadius) { + return 0; + } + if (args.distancePx <= snapRadius) { + return 1; + } + + const preSnapRange = Math.max(1, glowRadius - snapRadius); + const progressToSnap = (glowRadius - args.distancePx) / preSnapRange; + const eased = smoothstep(progressToSnap); + + return 0.22 + eased * 0.68; +} + export type CanvasMagnetTarget = { nodeId: string; handleId?: string; diff --git a/components/canvas/canvas-handle.tsx b/components/canvas/canvas-handle.tsx index bc03931..0fbd7b7 100644 --- a/components/canvas/canvas-handle.tsx +++ b/components/canvas/canvas-handle.tsx @@ -2,11 +2,14 @@ import { Handle, useConnection } from "@xyflow/react"; -import { HANDLE_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism"; +import { + resolveCanvasGlowStrength, +} from "@/components/canvas/canvas-connection-magnetism"; import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; import { canvasHandleAccentColor, - canvasHandleAccentColorWithAlpha, + canvasHandleGlowShadow, + type EdgeGlowColorMode, } from "@/lib/canvas-utils"; import { cn } from "@/lib/utils"; @@ -36,6 +39,7 @@ export default function CanvasHandle({ const connectionState = connection as { inProgress?: boolean; + isValid?: boolean | null; fromNode?: unknown; toNode?: unknown; fromHandle?: unknown; @@ -52,6 +56,32 @@ export default function CanvasHandle({ const handleId = normalizeHandleId(id); const targetHandleId = normalizeHandleId(activeTarget?.handleId); + + const toNodeId = + connectionState.toNode && + typeof connectionState.toNode === "object" && + "id" in connectionState.toNode && + typeof (connectionState.toNode as { id?: unknown }).id === "string" + ? ((connectionState.toNode as { id: string }).id ?? null) + : null; + + const toHandleMeta = + connectionState.toHandle && typeof connectionState.toHandle === "object" + ? (connectionState.toHandle as { id?: string | null; type?: "source" | "target" }) + : null; + const toHandleId = normalizeHandleId( + toHandleMeta?.id === null ? undefined : toHandleMeta?.id, + ); + const toHandleType = + toHandleMeta?.type === "source" || toHandleMeta?.type === "target" + ? toHandleMeta.type + : null; + + const colorMode: EdgeGlowColorMode = + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + const isActiveTarget = isConnectionDragActive && activeTarget !== null && @@ -59,21 +89,37 @@ export default function CanvasHandle({ activeTarget.handleType === type && targetHandleId === handleId; - const glowState: "idle" | "near" | "snapped" = isActiveTarget - ? activeTarget.distancePx <= HANDLE_SNAP_RADIUS_PX - ? "snapped" - : "near" - : "idle"; + const isNativeHoverTarget = + connectionState.inProgress === true && + toNodeId === nodeId && + toHandleType === type && + toHandleId === handleId; + + let glowStrength = 0; + + if (isActiveTarget) { + glowStrength = resolveCanvasGlowStrength({ + distancePx: activeTarget.distancePx, + }); + } else if (isNativeHoverTarget) { + glowStrength = connectionState.isValid === true ? 1 : 0.68; + } + + const glowState: "idle" | "near" | "snapped" = + glowStrength <= 0 ? "idle" : glowStrength >= 0.96 ? "snapped" : "near"; const accentColor = canvasHandleAccentColor({ nodeType, handleId, handleType: type, }); - const glowAlpha = glowState === "snapped" ? 0.62 : glowState === "near" ? 0.4 : 0; - const ringAlpha = glowState === "snapped" ? 0.34 : glowState === "near" ? 0.2 : 0; - const glowSize = glowState === "snapped" ? 14 : glowState === "near" ? 10 : 0; - const ringSize = glowState === "snapped" ? 6 : glowState === "near" ? 4 : 0; + const boxShadow = canvasHandleGlowShadow({ + nodeType, + handleId, + handleType: type, + strength: glowStrength, + colorMode, + }); return ( ); } diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 13ec262..6ab38ab 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; +import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism"; import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context"; interface CanvasInnerProps { @@ -676,6 +677,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { panOnDrag={flowPanOnDrag} selectionOnDrag={flowSelectionOnDrag} panActivationKeyCode="Space" + connectionRadius={HANDLE_GLOW_RADIUS_PX} + reconnectRadius={24} + edgesReconnectable proOptions={{ hideAttribution: true }} colorMode={resolvedTheme === "dark" ? "dark" : "light"} className={cn( diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index 8dbf683..746ca5f 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -14,10 +14,15 @@ import { useEffect, useMemo } from "react"; import { HANDLE_SNAP_RADIUS_PX, + resolveCanvasGlowStrength, resolveCanvasMagnetTarget, } from "@/components/canvas/canvas-connection-magnetism"; import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; -import { connectionLineAccentRgb } from "@/lib/canvas-utils"; +import { + connectionLineAccentRgb, + connectionLineGlowFilter, + type EdgeGlowColorMode, +} from "@/lib/canvas-utils"; function hasSameMagnetTarget( a: Parameters["setActiveTarget"]>[0], @@ -51,8 +56,9 @@ export default function CustomConnectionLine({ fromPosition, toPosition, connectionStatus, + pointer, }: ConnectionLineComponentProps) { - const { getNodes, getEdges } = useReactFlow(); + const { getNodes, getEdges, screenToFlowPosition } = useReactFlow(); const connection = useConnection(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const fromHandleId = fromHandle?.id; @@ -73,15 +79,20 @@ export default function CustomConnectionLine({ return null; } + const magnetPoint = + pointer && Number.isFinite(pointer.x) && Number.isFinite(pointer.y) + ? { x: pointer.x, y: pointer.y } + : { x: toX, y: toY }; + return resolveCanvasMagnetTarget({ - point: { x: toX, y: toY }, + point: magnetPoint, fromNodeId, fromHandleId: fromHandleId ?? undefined, fromHandleType, nodes: getNodes(), edges: getEdges(), }); - }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, toX, toY]); + }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, pointer, toX, toY]); useEffect(() => { if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) { @@ -91,12 +102,21 @@ export default function CustomConnectionLine({ }, [activeTarget, resolvedMagnetTarget, setActiveTarget]); const magnetTarget = activeTarget ?? resolvedMagnetTarget; + const glowStrength = magnetTarget + ? resolveCanvasGlowStrength({ + distancePx: magnetTarget.distancePx, + }) + : 0; const snappedTarget = magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX ? magnetTarget : null; - const targetX = snappedTarget?.centerX ?? toX; - const targetY = snappedTarget?.centerY ?? toY; + const snappedFlowPoint = + snappedTarget === null + ? null + : screenToFlowPosition({ x: snappedTarget.centerX, y: snappedTarget.centerY }); + const targetX = snappedFlowPoint?.x ?? toX; + const targetY = snappedFlowPoint?.y ?? toY; const pathParams = { sourceX: fromX, @@ -130,10 +150,17 @@ export default function CustomConnectionLine({ const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId); const opacity = connectionStatus === "invalid" ? 0.45 : 1; - const strokeWidth = snappedTarget ? 3.25 : 2.5; - const filter = snappedTarget - ? `drop-shadow(0 0 3px rgba(${r}, ${g}, ${b}, 0.7)) drop-shadow(0 0 8px rgba(${r}, ${g}, ${b}, 0.48))` - : undefined; + const colorMode: EdgeGlowColorMode = + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + const strokeWidth = 2.5 + glowStrength * 0.75; + const filter = connectionLineGlowFilter({ + nodeType: fromNode.type, + handleId: fromHandleId, + strength: glowStrength, + colorMode, + }); return ( = 1) { + return 1; + } + return value; +} + +function lerp(min: number, max: number, t: number): number { + return min + (max - min) * t; +} + +export function canvasHandleGlowShadow(args: { + nodeType: string | undefined; + handleId?: string | null; + handleType: "source" | "target"; + strength: number; + colorMode: EdgeGlowColorMode; +}): string | undefined { + const strength = clampUnit(args.strength); + if (strength <= 0) { + return undefined; + } + + const [r, g, b] = canvasHandleAccentRgb(args); + const isDark = args.colorMode === "dark"; + + const ringAlpha = isDark + ? lerp(0.08, 0.3, strength) + : lerp(0.06, 0.2, strength); + const glowAlpha = isDark + ? lerp(0.12, 0.58, strength) + : lerp(0.08, 0.34, strength); + const ringSize = isDark + ? lerp(1.8, 6.4, strength) + : lerp(1.5, 5.2, strength); + const glowSize = isDark + ? lerp(4.5, 15, strength) + : lerp(3.5, 12, strength); + + return `0 0 0 ${ringSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${ringAlpha.toFixed(3)}), 0 0 ${glowSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${glowAlpha.toFixed(3)})`; +} + +export function connectionLineGlowFilter(args: { + nodeType: string | undefined; + handleId: string | null | undefined; + strength: number; + colorMode: EdgeGlowColorMode; +}): string | undefined { + const strength = clampUnit(args.strength); + if (strength <= 0) { + return undefined; + } + + const [r, g, b] = connectionLineAccentRgb(args.nodeType, args.handleId); + const isDark = args.colorMode === "dark"; + + const innerAlpha = isDark + ? lerp(0.22, 0.72, strength) + : lerp(0.12, 0.42, strength); + const outerAlpha = isDark + ? lerp(0.12, 0.38, strength) + : lerp(0.06, 0.2, strength); + const innerBlur = isDark + ? lerp(2.4, 4.2, strength) + : lerp(2, 3.4, strength); + const outerBlur = isDark + ? lerp(5.4, 9.8, strength) + : lerp(4.6, 7.8, strength); + + return `drop-shadow(0 0 ${innerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${innerAlpha.toFixed(3)})) drop-shadow(0 0 ${outerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${outerAlpha.toFixed(3)}))`; +} + /** * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect). */