feat(canvas): snap connection preview to magnet targets

This commit is contained in:
2026-04-11 09:04:59 +02:00
parent db71b2485a
commit baeb709acd
3 changed files with 273 additions and 3 deletions

View File

@@ -0,0 +1,197 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import {
ConnectionLineType,
Position,
type ConnectionLineComponentProps,
} from "@xyflow/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
CanvasConnectionMagnetismProvider,
} from "@/components/canvas/canvas-connection-magnetism-context";
import CustomConnectionLine from "@/components/canvas/custom-connection-line";
import { connectionLineAccentRgb } from "@/lib/canvas-utils";
const reactFlowStateRef: {
current: {
nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>;
edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>;
};
} = {
current: {
nodes: [],
edges: [],
},
};
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
getNodes: () => reactFlowStateRef.current.nodes,
getEdges: () => reactFlowStateRef.current.edges,
}),
};
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const baseProps = {
connectionLineType: ConnectionLineType.Straight,
fromNode: {
id: "source-node",
type: "image",
},
fromHandle: {
id: "image-out",
type: "source",
nodeId: "source-node",
position: Position.Right,
x: 0,
y: 0,
width: 12,
height: 12,
},
fromX: 20,
fromY: 40,
toX: 290,
toY: 210,
fromPosition: Position.Right,
toPosition: Position.Left,
connectionStatus: "valid",
} as unknown as ConnectionLineComponentProps;
describe("CustomConnectionLine", () => {
let container: HTMLDivElement | null = null;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
container?.remove();
document
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
.forEach((element) => element.remove());
container = null;
root = null;
});
function renderLine(args?: {
withMagnetHandle?: boolean;
connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
}) {
document
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
.forEach((element) => element.remove());
reactFlowStateRef.current = {
nodes: [
{ id: "source-node", type: "image", position: { x: 0, y: 0 }, data: {} },
{ id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} },
],
edges: [],
};
if (args?.withMagnetHandle && container) {
const handleEl = document.createElement("div");
handleEl.setAttribute("data-testid", "custom-line-magnet-handle");
handleEl.setAttribute("data-node-id", "target-node");
handleEl.setAttribute("data-handle-id", "");
handleEl.setAttribute("data-handle-type", "target");
handleEl.getBoundingClientRect = () =>
({
x: 294,
y: 214,
top: 214,
left: 294,
right: 306,
bottom: 226,
width: 12,
height: 12,
toJSON: () => ({}),
}) as DOMRect;
document.body.appendChild(handleEl);
}
act(() => {
root?.render(
<CanvasConnectionMagnetismProvider>
<svg>
<CustomConnectionLine
{...baseProps}
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("strengthens stroke visual feedback while snapped", () => {
renderLine();
const idlePath = getPath();
const idleStrokeWidth = idlePath.style.strokeWidth;
const idleFilter = idlePath.style.filter;
renderLine({
withMagnetHandle: true,
});
const snappedPath = getPath();
expect(snappedPath.style.strokeWidth).not.toBe(idleStrokeWidth);
expect(snappedPath.style.filter).not.toBe(idleFilter);
});
it("keeps invalid connection opacity behavior while snapped", () => {
renderLine({
withMagnetHandle: true,
connectionStatus: "invalid",
});
const path = getPath();
expect(path.style.opacity).toBe("0.45");
});
});

View File

@@ -7,9 +7,38 @@ import {
getSmoothStepPath, getSmoothStepPath,
getStraightPath, getStraightPath,
type ConnectionLineComponentProps, type ConnectionLineComponentProps,
useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { useEffect, useMemo } from "react";
import {
HANDLE_SNAP_RADIUS_PX,
resolveCanvasMagnetTarget,
} from "@/components/canvas/canvas-connection-magnetism";
import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
import { connectionLineAccentRgb } from "@/lib/canvas-utils"; import { connectionLineAccentRgb } from "@/lib/canvas-utils";
function hasSameMagnetTarget(
a: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0],
b: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["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({ export default function CustomConnectionLine({
connectionLineType, connectionLineType,
fromNode, fromNode,
@@ -22,12 +51,50 @@ export default function CustomConnectionLine({
toPosition, toPosition,
connectionStatus, connectionStatus,
}: ConnectionLineComponentProps) { }: ConnectionLineComponentProps) {
const { getNodes, getEdges } = useReactFlow();
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const fromHandleType =
fromHandle?.type === "source" || fromHandle?.type === "target"
? fromHandle.type
: null;
const resolvedMagnetTarget = useMemo(() => {
if (!fromHandleType || !fromNode?.id) {
return null;
}
return resolveCanvasMagnetTarget({
point: { x: toX, y: toY },
fromNodeId: fromNode.id,
fromHandleId: fromHandle?.id ?? undefined,
fromHandleType,
nodes: getNodes(),
edges: getEdges(),
});
}, [fromHandle?.id, fromHandleType, fromNode?.id, getEdges, getNodes, toX, toY]);
useEffect(() => {
if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) {
return;
}
setActiveTarget(resolvedMagnetTarget);
}, [activeTarget, resolvedMagnetTarget, setActiveTarget]);
const magnetTarget = activeTarget ?? resolvedMagnetTarget;
const snappedTarget =
magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX
? magnetTarget
: null;
const targetX = snappedTarget?.centerX ?? toX;
const targetY = snappedTarget?.centerY ?? toY;
const pathParams = { const pathParams = {
sourceX: fromX, sourceX: fromX,
sourceY: fromY, sourceY: fromY,
sourcePosition: fromPosition, sourcePosition: fromPosition,
targetX: toX, targetX,
targetY: toY, targetY,
targetPosition: toPosition, targetPosition: toPosition,
}; };
@@ -54,6 +121,10 @@ export default function CustomConnectionLine({
const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id); const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id);
const opacity = connectionStatus === "invalid" ? 0.45 : 1; const opacity = connectionStatus === "invalid" ? 0.45 : 1;
const strokeWidth = snappedTarget ? 3.25 : 2.5;
const filter = snappedTarget
? `drop-shadow(0 0 3px rgba(${r}, ${g}, ${b}, 0.7)) drop-shadow(0 0 8px rgba(${r}, ${g}, ${b}, 0.48))`
: undefined;
return ( return (
<path <path
@@ -62,9 +133,10 @@ export default function CustomConnectionLine({
className="ls-connection-line-marching" className="ls-connection-line-marching"
style={{ style={{
stroke: `rgb(${r}, ${g}, ${b})`, stroke: `rgb(${r}, ${g}, ${b})`,
strokeWidth: 2.5, strokeWidth,
strokeLinecap: "round", strokeLinecap: "round",
strokeDasharray: "10 8", strokeDasharray: "10 8",
filter,
opacity, opacity,
}} }}
/> />

View File

@@ -26,6 +26,7 @@ export default defineConfig({
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
"components/canvas/__tests__/canvas-delete-handlers.test.tsx", "components/canvas/__tests__/canvas-delete-handlers.test.tsx",
"components/canvas/__tests__/canvas-handle.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__/canvas-media-utils.test.ts",
"components/canvas/__tests__/base-node-wrapper.test.tsx", "components/canvas/__tests__/base-node-wrapper.test.tsx",
"components/canvas/__tests__/use-node-local-data.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx",