Merge branch 'feat/canvas-magnetism-20260411-082412'

This commit is contained in:
2026-04-11 10:47:03 +02:00
50 changed files with 2589 additions and 354 deletions

View File

@@ -7,7 +7,6 @@ import { resolveDroppedConnectionTarget } from "@/components/canvas/canvas-helpe
function createNode(overrides: Partial<RFNode> & Pick<RFNode, "id">): RFNode {
return {
id: overrides.id,
position: { x: 0, y: 0 },
data: {},
...overrides,
@@ -40,6 +39,34 @@ function makeNodeElement(id: string, rect: Partial<DOMRect> = {}): HTMLElement {
return element;
}
function makeHandleElement(args: {
nodeId: string;
handleType: "source" | "target";
handleId?: string;
rect: Partial<DOMRect>;
}): HTMLElement {
const element = document.createElement("div");
element.className = "react-flow__handle";
element.dataset.nodeId = args.nodeId;
element.dataset.handleType = args.handleType;
if (args.handleId !== undefined) {
element.dataset.handleId = args.handleId;
}
vi.spyOn(element, "getBoundingClientRect").mockReturnValue({
x: args.rect.left ?? 0,
y: args.rect.top ?? 0,
top: args.rect.top ?? 0,
left: args.rect.left ?? 0,
right: args.rect.right ?? 10,
bottom: args.rect.bottom ?? 10,
width: args.rect.width ?? 10,
height: args.rect.height ?? 10,
toJSON: () => ({}),
} as DOMRect);
document.body.appendChild(element);
return element;
}
describe("resolveDroppedConnectionTarget", () => {
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",
});
});
});

View File

@@ -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<HTMLDivElement> & {
className?: string;
style?: React.CSSProperties;
}) => <div className={className} style={style} {...props} />,
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<React.ComponentProps<typeof CanvasHandle>>;
}) {
connectionStateRef.current = args?.connectionState ?? { inProgress: false };
await act(async () => {
root?.render(
<CanvasConnectionMagnetismProvider>
<MagnetTargetSetter target={args?.activeTarget ?? null} />
<CanvasHandle
nodeId="node-1"
nodeType="image"
type="target"
position={"left" as React.ComponentProps<typeof CanvasHandle>["position"]}
id="image-in"
{...args?.props}
/>
</CanvasConnectionMagnetismProvider>,
);
});
}
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<typeof CanvasHandle>["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");
});
});

View File

@@ -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;
}) => (
<div
data-canvas-handle="true"
data-handle-id={id ?? ""}
data-handle-type={type}
data-node-id={nodeId}
data-node-type={nodeType ?? ""}
data-top={typeof style?.top === "string" ? style.top : ""}
/>
),
}));
vi.mock("../nodes/base-node-wrapper", () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
@@ -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%"');
});
});

View File

@@ -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<typeof import("@xyflow/react")>("@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(
<CanvasConnectionMagnetismProvider>
<svg>
<CustomConnectionLine
{...lineProps}
connectionStatus={args?.connectionStatus ?? "valid"}
/>
</svg>
</CanvasConnectionMagnetismProvider>,
);
});
}
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);
});
});

View File

@@ -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;
}) => (
<div
data-canvas-handle="true"
data-handle-id={id ?? ""}
data-handle-type={type}
data-node-id={nodeId}
data-node-type={nodeType ?? ""}
data-top={typeof style?.top === "string" ? style.top : ""}
/>
),
}));
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();
});
});

View File

@@ -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<typeof useCanvasConnections> | 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<typeof vi.fn>;
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<RFNode[]>(
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 (
<CanvasConnectionMagnetismProvider>
<HookHarnessInner {...props} />
</CanvasConnectionMagnetismProvider>
);
}
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
initialMagnetTarget={{
nodeId: "node-target",
handleId: "base",
handleType: "target",
centerX: 320,
centerY: 180,
distancePx: 12,
}}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
showConnectionRejectedToast={showConnectionRejectedToast}
initialMagnetTarget={{
nodeId: "node-source",
handleType: "target",
centerX: 100,
centerY: 100,
distancePx: 10,
}}
/>,
);
});
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(
<HookHarness
helperResult={null}
initialMagnetTarget={{
nodeId: "node-target",
handleType: "target",
centerX: 200,
centerY: 220,
distancePx: 14,
}}
/>,
);
});
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(
<HookHarness
helperResult={null}
runCreateEdgeMutation={runCreateEdgeMutation}
edges={[
{
id: "edge-1",
source: "node-source",
target: "node-target",
targetHandle: "base",
},
]}
nodes={[
{ id: "node-source", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "node-target", type: "mixer", position: { x: 300, y: 200 }, data: {} },
]}
initialMagnetTarget={{
nodeId: "node-target",
handleType: "target",
handleId: "overlay",
centerX: 300,
centerY: 180,
distancePx: 11,
}}
/>,
);
});
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();
});
});

View File

@@ -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 {