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/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/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/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx
new file mode 100644
index 0000000..da8033b
--- /dev/null
+++ b/components/canvas/__tests__/canvas-handle.test.tsx
@@ -0,0 +1,346 @@
+// @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_GLOW_RADIUS_PX,
+ 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;
+ 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 },
+};
+
+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();
+ document.documentElement.classList.remove("dark");
+ container = null;
+ root = null;
+ });
+
+ async function renderHandle(args?: {
+ connectionState?: {
+ 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;
+ handleId?: string;
+ handleType: "source" | "target";
+ centerX: number;
+ centerY: number;
+ distancePx: number;
+ } | null;
+ props?: Partial>;
+ }) {
+ connectionStateRef.current = args?.connectionState ?? { inProgress: false };
+
+ await act(async () => {
+ root?.render(
+
+
+ ["position"]}
+ id="image-in"
+ {...args?.props}
+ />
+ ,
+ );
+ });
+ }
+
+ 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({
+ connectionState: { 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({
+ connectionState: { 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({
+ connectionState: { 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("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 },
+ 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("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("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: {
+ nodeId: "node-2",
+ id: undefined,
+ type: "source",
+ position: "right" as React.ComponentProps["position"],
+ },
+ });
+
+ 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/__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__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx
new file mode 100644
index 0000000..bb583ce
--- /dev/null
+++ b/components/canvas/__tests__/custom-connection-line.test.tsx
@@ -0,0 +1,310 @@
+// @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 }>;
+ screenToFlowPosition: ({ x, y }: { x: number; y: number }) => { x: number; y: number };
+ };
+} = {
+ current: {
+ nodes: [],
+ edges: [],
+ screenToFlowPosition: ({ x, y }) => ({ x, y }),
+ },
+};
+
+const connectionStateRef: {
+ current: {
+ fromHandle?: { type?: "source" | "target" };
+ };
+} = {
+ current: {
+ fromHandle: { type: "source" },
+ },
+};
+
+vi.mock("@xyflow/react", async () => {
+ const actual = await vi.importActual("@xyflow/react");
+
+ return {
+ ...actual,
+ useReactFlow: () => ({
+ getNodes: () => reactFlowStateRef.current.nodes,
+ getEdges: () => reactFlowStateRef.current.edges,
+ screenToFlowPosition: reactFlowStateRef.current.screenToFlowPosition,
+ }),
+ useConnection: () => connectionStateRef.current,
+ };
+});
+
+(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());
+ document.documentElement.classList.remove("dark");
+ container = null;
+ root = null;
+ });
+
+ function renderLine(args?: {
+ withMagnetHandle?: boolean;
+ connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
+ omitFromHandleType?: boolean;
+ toX?: number;
+ toY?: number;
+ pointer?: { x: number; y: number };
+ }) {
+ 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: [],
+ screenToFlowPosition: ({ x, y }) => ({ x, y }),
+ };
+
+ connectionStateRef.current = {
+ fromHandle: { type: "source" },
+ };
+
+ 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(() => {
+ 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;
+
+ 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("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();
+ 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("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,
+ connectionStatus: "invalid",
+ });
+
+ 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/__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/__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/__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
new file mode 100644
index 0000000..b2a5504
--- /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);
+
+const FALLBACK_MAGNETISM_STATE: CanvasConnectionMagnetismState = {
+ activeTarget: null,
+ setActiveTarget: () => undefined,
+};
+
+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);
+ return context ?? FALLBACK_MAGNETISM_STATE;
+}
diff --git a/components/canvas/canvas-connection-magnetism.ts b/components/canvas/canvas-connection-magnetism.ts
new file mode 100644
index 0000000..949b3f7
--- /dev/null
+++ b/components/canvas/canvas-connection-magnetism.ts
@@ -0,0 +1,244 @@
+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;
+
+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;
+ 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-handle.tsx b/components/canvas/canvas-handle.tsx
new file mode 100644
index 0000000..0fbd7b7
--- /dev/null
+++ b/components/canvas/canvas-handle.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { Handle, useConnection } from "@xyflow/react";
+
+import {
+ resolveCanvasGlowStrength,
+} from "@/components/canvas/canvas-connection-magnetism";
+import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
+import {
+ canvasHandleAccentColor,
+ canvasHandleGlowShadow,
+ type EdgeGlowColorMode,
+} 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 connectionState = connection as {
+ inProgress?: boolean;
+ isValid?: boolean | null;
+ 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 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 &&
+ activeTarget.nodeId === nodeId &&
+ activeTarget.handleType === type &&
+ targetHandleId === handleId;
+
+ 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 boxShadow = canvasHandleGlowShadow({
+ nodeType,
+ handleId,
+ handleType: type,
+ strength: glowStrength,
+ colorMode,
+ });
+
+ return (
+
+ );
+}
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/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..6ab38ab 100644
--- a/components/canvas/canvas.tsx
+++ b/components/canvas/canvas.tsx
@@ -78,6 +78,8 @@ 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 {
canvasId: Id<"canvases">;
@@ -675,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(
@@ -709,7 +714,9 @@ interface CanvasProps {
export default function Canvas({ canvasId }: CanvasProps) {
return (
-
+
+
+
);
}
diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx
index ca049a4..746ca5f 100644
--- a/components/canvas/custom-connection-line.tsx
+++ b/components/canvas/custom-connection-line.tsx
@@ -7,8 +7,43 @@ import {
getSmoothStepPath,
getStraightPath,
type ConnectionLineComponentProps,
+ useConnection,
+ useReactFlow,
} from "@xyflow/react";
-import { connectionLineAccentRgb } from "@/lib/canvas-utils";
+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,
+ connectionLineGlowFilter,
+ type EdgeGlowColorMode,
+} 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,
@@ -21,13 +56,74 @@ export default function CustomConnectionLine({
fromPosition,
toPosition,
connectionStatus,
+ pointer,
}: ConnectionLineComponentProps) {
+ const { getNodes, getEdges, screenToFlowPosition } = 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
+ : connectionFromHandleType ?? "source";
+
+ const resolvedMagnetTarget = useMemo(() => {
+ if (!fromHandleType || !fromNodeId) {
+ 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: magnetPoint,
+ fromNodeId,
+ fromHandleId: fromHandleId ?? undefined,
+ fromHandleType,
+ nodes: getNodes(),
+ edges: getEdges(),
+ });
+ }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, pointer, toX, toY]);
+
+ useEffect(() => {
+ if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) {
+ return;
+ }
+ setActiveTarget(resolvedMagnetTarget);
+ }, [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 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,
sourceY: fromY,
sourcePosition: fromPosition,
- targetX: toX,
- targetY: toY,
+ targetX,
+ targetY,
targetPosition: toPosition,
};
@@ -52,8 +148,19 @@ 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 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 (
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}
-
-
- (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 {
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
diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts
index 3a617e6..56e9bfc 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,158 @@ 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;
+}
+
+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})`;
+}
+
+function clampUnit(value: number): number {
+ if (!Number.isFinite(value)) {
+ return 0;
+ }
+ if (value <= 0) {
+ return 0;
+ }
+ if (value >= 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).
@@ -145,13 +284,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";
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: () => ({
diff --git a/vitest.config.ts b/vitest.config.ts
index fa90d83..319c8d0 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -25,6 +25,8 @@ 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__/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",