fix(canvas): strengthen pre-snap glow and reconnect drag UX
This commit is contained in:
@@ -4,7 +4,10 @@ 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_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism";
|
||||
import {
|
||||
HANDLE_GLOW_RADIUS_PX,
|
||||
HANDLE_SNAP_RADIUS_PX,
|
||||
} from "@/components/canvas/canvas-connection-magnetism";
|
||||
import {
|
||||
CanvasConnectionMagnetismProvider,
|
||||
useCanvasConnectionMagnetism,
|
||||
@@ -15,6 +18,9 @@ const connectionStateRef: {
|
||||
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 },
|
||||
@@ -78,6 +84,7 @@ describe("CanvasHandle", () => {
|
||||
});
|
||||
}
|
||||
container?.remove();
|
||||
document.documentElement.classList.remove("dark");
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
@@ -87,6 +94,9 @@ describe("CanvasHandle", () => {
|
||||
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;
|
||||
@@ -186,6 +196,42 @@ describe("CanvasHandle", () => {
|
||||
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 },
|
||||
@@ -223,6 +269,61 @@ describe("CanvasHandle", () => {
|
||||
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: {
|
||||
|
||||
@@ -19,11 +19,13 @@ 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 }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,6 +47,7 @@ vi.mock("@xyflow/react", async () => {
|
||||
useReactFlow: () => ({
|
||||
getNodes: () => reactFlowStateRef.current.nodes,
|
||||
getEdges: () => reactFlowStateRef.current.edges,
|
||||
screenToFlowPosition: reactFlowStateRef.current.screenToFlowPosition,
|
||||
}),
|
||||
useConnection: () => connectionStateRef.current,
|
||||
};
|
||||
@@ -97,6 +100,7 @@ describe("CustomConnectionLine", () => {
|
||||
document
|
||||
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
|
||||
.forEach((element) => element.remove());
|
||||
document.documentElement.classList.remove("dark");
|
||||
container = null;
|
||||
root = null;
|
||||
});
|
||||
@@ -105,6 +109,9 @@ describe("CustomConnectionLine", () => {
|
||||
withMagnetHandle?: boolean;
|
||||
connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
|
||||
omitFromHandleType?: boolean;
|
||||
toX?: number;
|
||||
toY?: number;
|
||||
pointer?: { x: number; y: number };
|
||||
}) {
|
||||
document
|
||||
.querySelectorAll("[data-testid='custom-line-magnet-handle']")
|
||||
@@ -116,6 +123,7 @@ describe("CustomConnectionLine", () => {
|
||||
{ id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} },
|
||||
],
|
||||
edges: [],
|
||||
screenToFlowPosition: ({ x, y }) => ({ x, y }),
|
||||
};
|
||||
|
||||
connectionStateRef.current = {
|
||||
@@ -144,11 +152,14 @@ describe("CustomConnectionLine", () => {
|
||||
}
|
||||
|
||||
act(() => {
|
||||
const lineProps = {
|
||||
...baseProps,
|
||||
fromHandle: {
|
||||
...baseProps.fromHandle,
|
||||
...(args?.omitFromHandleType ? { type: undefined } : null),
|
||||
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;
|
||||
|
||||
@@ -220,6 +231,29 @@ describe("CustomConnectionLine", () => {
|
||||
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,
|
||||
@@ -229,4 +263,48 @@ describe("CustomConnectionLine", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user