feat(canvas): snap connection preview to magnet targets
This commit is contained in:
197
components/canvas/__tests__/custom-connection-line.test.tsx
Normal file
197
components/canvas/__tests__/custom-connection-line.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user