diff --git a/components/canvas/__tests__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx new file mode 100644 index 0000000..6e8c16e --- /dev/null +++ b/components/canvas/__tests__/custom-connection-line.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment jsdom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { + ConnectionLineType, + Position, + type ConnectionLineComponentProps, +} from "@xyflow/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + CanvasConnectionMagnetismProvider, +} from "@/components/canvas/canvas-connection-magnetism-context"; +import CustomConnectionLine from "@/components/canvas/custom-connection-line"; +import { connectionLineAccentRgb } from "@/lib/canvas-utils"; + +const reactFlowStateRef: { + current: { + nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>; + edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>; + }; +} = { + current: { + nodes: [], + edges: [], + }, +}; + +vi.mock("@xyflow/react", async () => { + const actual = await vi.importActual("@xyflow/react"); + + return { + ...actual, + useReactFlow: () => ({ + getNodes: () => reactFlowStateRef.current.nodes, + getEdges: () => reactFlowStateRef.current.edges, + }), + }; +}); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const baseProps = { + connectionLineType: ConnectionLineType.Straight, + fromNode: { + id: "source-node", + type: "image", + }, + fromHandle: { + id: "image-out", + type: "source", + nodeId: "source-node", + position: Position.Right, + x: 0, + y: 0, + width: 12, + height: 12, + }, + fromX: 20, + fromY: 40, + toX: 290, + toY: 210, + fromPosition: Position.Right, + toPosition: Position.Left, + connectionStatus: "valid", +} as unknown as ConnectionLineComponentProps; + +describe("CustomConnectionLine", () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + document + .querySelectorAll("[data-testid='custom-line-magnet-handle']") + .forEach((element) => element.remove()); + container = null; + root = null; + }); + + function renderLine(args?: { + withMagnetHandle?: boolean; + connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; + }) { + document + .querySelectorAll("[data-testid='custom-line-magnet-handle']") + .forEach((element) => element.remove()); + + reactFlowStateRef.current = { + nodes: [ + { id: "source-node", type: "image", position: { x: 0, y: 0 }, data: {} }, + { id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} }, + ], + edges: [], + }; + + if (args?.withMagnetHandle && container) { + const handleEl = document.createElement("div"); + handleEl.setAttribute("data-testid", "custom-line-magnet-handle"); + handleEl.setAttribute("data-node-id", "target-node"); + handleEl.setAttribute("data-handle-id", ""); + handleEl.setAttribute("data-handle-type", "target"); + handleEl.getBoundingClientRect = () => + ({ + x: 294, + y: 214, + top: 214, + left: 294, + right: 306, + bottom: 226, + width: 12, + height: 12, + toJSON: () => ({}), + }) as DOMRect; + document.body.appendChild(handleEl); + } + + act(() => { + root?.render( + + + + + , + ); + }); + } + + function getPath() { + const path = container?.querySelector("path"); + if (!(path instanceof Element) || path.tagName.toLowerCase() !== "path") { + throw new Error("Connection line path not rendered"); + } + return path as SVGElement; + } + + it("renders with the existing accent color when no magnet target is active", () => { + renderLine(); + + const [r, g, b] = connectionLineAccentRgb("image", "image-out"); + const path = getPath(); + + expect(path.style.stroke).toBe(`rgb(${r}, ${g}, ${b})`); + expect(path.getAttribute("d")).toContain("290"); + expect(path.getAttribute("d")).toContain("210"); + }); + + it("snaps endpoint to active magnet target center", () => { + renderLine({ + withMagnetHandle: true, + }); + + const path = getPath(); + expect(path.getAttribute("d")).toContain("300"); + expect(path.getAttribute("d")).toContain("220"); + }); + + it("strengthens stroke visual feedback while snapped", () => { + renderLine(); + const idlePath = getPath(); + const idleStrokeWidth = idlePath.style.strokeWidth; + const idleFilter = idlePath.style.filter; + + renderLine({ + withMagnetHandle: true, + }); + const snappedPath = getPath(); + + expect(snappedPath.style.strokeWidth).not.toBe(idleStrokeWidth); + expect(snappedPath.style.filter).not.toBe(idleFilter); + }); + + it("keeps invalid connection opacity behavior while snapped", () => { + renderLine({ + withMagnetHandle: true, + connectionStatus: "invalid", + }); + + const path = getPath(); + expect(path.style.opacity).toBe("0.45"); + }); +}); diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index ca049a4..2824177 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -7,9 +7,38 @@ import { getSmoothStepPath, getStraightPath, type ConnectionLineComponentProps, + useReactFlow, } from "@xyflow/react"; +import { useEffect, useMemo } from "react"; + +import { + HANDLE_SNAP_RADIUS_PX, + resolveCanvasMagnetTarget, +} from "@/components/canvas/canvas-connection-magnetism"; +import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; import { connectionLineAccentRgb } from "@/lib/canvas-utils"; +function hasSameMagnetTarget( + a: Parameters["setActiveTarget"]>[0], + b: Parameters["setActiveTarget"]>[0], +): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + + return ( + a.nodeId === b.nodeId && + a.handleId === b.handleId && + a.handleType === b.handleType && + a.centerX === b.centerX && + a.centerY === b.centerY && + a.distancePx === b.distancePx + ); +} + export default function CustomConnectionLine({ connectionLineType, fromNode, @@ -22,12 +51,50 @@ export default function CustomConnectionLine({ toPosition, connectionStatus, }: ConnectionLineComponentProps) { + const { getNodes, getEdges } = useReactFlow(); + const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); + + const fromHandleType = + fromHandle?.type === "source" || fromHandle?.type === "target" + ? fromHandle.type + : null; + + const resolvedMagnetTarget = useMemo(() => { + if (!fromHandleType || !fromNode?.id) { + return null; + } + + return resolveCanvasMagnetTarget({ + point: { x: toX, y: toY }, + fromNodeId: fromNode.id, + fromHandleId: fromHandle?.id ?? undefined, + fromHandleType, + nodes: getNodes(), + edges: getEdges(), + }); + }, [fromHandle?.id, fromHandleType, fromNode?.id, getEdges, getNodes, toX, toY]); + + useEffect(() => { + if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) { + return; + } + setActiveTarget(resolvedMagnetTarget); + }, [activeTarget, resolvedMagnetTarget, setActiveTarget]); + + const magnetTarget = activeTarget ?? resolvedMagnetTarget; + const snappedTarget = + magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX + ? magnetTarget + : null; + const targetX = snappedTarget?.centerX ?? toX; + const targetY = snappedTarget?.centerY ?? toY; + const pathParams = { sourceX: fromX, sourceY: fromY, sourcePosition: fromPosition, - targetX: toX, - targetY: toY, + targetX, + targetY, targetPosition: toPosition, }; @@ -54,6 +121,10 @@ export default function CustomConnectionLine({ const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandle.id); const opacity = connectionStatus === "invalid" ? 0.45 : 1; + const strokeWidth = snappedTarget ? 3.25 : 2.5; + const filter = snappedTarget + ? `drop-shadow(0 0 3px rgba(${r}, ${g}, ${b}, 0.7)) drop-shadow(0 0 8px rgba(${r}, ${g}, ${b}, 0.48))` + : undefined; return ( diff --git a/vitest.config.ts b/vitest.config.ts index f5af42b..78563a3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ "components/canvas/__tests__/use-canvas-node-interactions.test.tsx", "components/canvas/__tests__/canvas-delete-handlers.test.tsx", "components/canvas/__tests__/canvas-handle.test.tsx", + "components/canvas/__tests__/custom-connection-line.test.tsx", "components/canvas/__tests__/canvas-media-utils.test.ts", "components/canvas/__tests__/base-node-wrapper.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx",