fix(canvas): restore visible handle glow during drag

This commit is contained in:
2026-04-11 09:20:39 +02:00
parent e33e032cfc
commit 079bc34ce4
4 changed files with 100 additions and 12 deletions

View File

@@ -10,7 +10,13 @@ import {
useCanvasConnectionMagnetism, useCanvasConnectionMagnetism,
} from "@/components/canvas/canvas-connection-magnetism-context"; } from "@/components/canvas/canvas-connection-magnetism-context";
const connectionStateRef: { current: { inProgress: boolean } } = { const connectionStateRef: {
current: {
inProgress?: boolean;
fromNode?: { id: string };
fromHandle?: { id?: string; type?: "source" | "target" };
};
} = {
current: { inProgress: false }, current: { inProgress: false },
}; };
@@ -77,7 +83,11 @@ describe("CanvasHandle", () => {
}); });
async function renderHandle(args?: { async function renderHandle(args?: {
inProgress?: boolean; connectionState?: {
inProgress?: boolean;
fromNode?: { id: string };
fromHandle?: { id?: string; type?: "source" | "target" };
};
activeTarget?: { activeTarget?: {
nodeId: string; nodeId: string;
handleId?: string; handleId?: string;
@@ -88,7 +98,7 @@ describe("CanvasHandle", () => {
} | null; } | null;
props?: Partial<React.ComponentProps<typeof CanvasHandle>>; props?: Partial<React.ComponentProps<typeof CanvasHandle>>;
}) { }) {
connectionStateRef.current = { inProgress: args?.inProgress ?? false }; connectionStateRef.current = args?.connectionState ?? { inProgress: false };
await act(async () => { await act(async () => {
root?.render( root?.render(
@@ -98,7 +108,7 @@ describe("CanvasHandle", () => {
nodeId="node-1" nodeId="node-1"
nodeType="image" nodeType="image"
type="target" type="target"
position="left" position={"left" as React.ComponentProps<typeof CanvasHandle>["position"]}
id="image-in" id="image-in"
{...args?.props} {...args?.props}
/> />
@@ -128,7 +138,7 @@ describe("CanvasHandle", () => {
it("turns on near-target glow when this handle is active target", async () => { it("turns on near-target glow when this handle is active target", async () => {
await renderHandle({ await renderHandle({
inProgress: true, connectionState: { inProgress: true },
activeTarget: { activeTarget: {
nodeId: "node-1", nodeId: "node-1",
handleId: "image-in", handleId: "image-in",
@@ -145,7 +155,7 @@ describe("CanvasHandle", () => {
it("renders a stronger glow in snapped state than near state", async () => { it("renders a stronger glow in snapped state than near state", async () => {
await renderHandle({ await renderHandle({
inProgress: true, connectionState: { inProgress: true },
activeTarget: { activeTarget: {
nodeId: "node-1", nodeId: "node-1",
handleId: "image-in", handleId: "image-in",
@@ -160,7 +170,7 @@ describe("CanvasHandle", () => {
const nearGlow = nearHandle.style.boxShadow; const nearGlow = nearHandle.style.boxShadow;
await renderHandle({ await renderHandle({
inProgress: true, connectionState: { inProgress: true },
activeTarget: { activeTarget: {
nodeId: "node-1", nodeId: "node-1",
handleId: "image-in", handleId: "image-in",
@@ -178,7 +188,7 @@ describe("CanvasHandle", () => {
it("does not glow for non-target handles during the same drag", async () => { it("does not glow for non-target handles during the same drag", async () => {
await renderHandle({ await renderHandle({
inProgress: true, connectionState: { inProgress: true },
activeTarget: { activeTarget: {
nodeId: "other-node", nodeId: "other-node",
handleId: "image-in", handleId: "image-in",
@@ -193,13 +203,33 @@ describe("CanvasHandle", () => {
expect(handle.getAttribute("data-glow-state")).toBe("idle"); 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("emits stable handle geometry data attributes", async () => { it("emits stable handle geometry data attributes", async () => {
await renderHandle({ await renderHandle({
props: { props: {
nodeId: "node-2", nodeId: "node-2",
id: undefined, id: undefined,
type: "source", type: "source",
position: "right", position: "right" as React.ComponentProps<typeof CanvasHandle>["position"],
}, },
}); });

View File

@@ -27,6 +27,16 @@ const reactFlowStateRef: {
}, },
}; };
const connectionStateRef: {
current: {
fromHandle?: { type?: "source" | "target" };
};
} = {
current: {
fromHandle: { type: "source" },
},
};
vi.mock("@xyflow/react", async () => { vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react"); const actual = await vi.importActual<typeof import("@xyflow/react")>("@xyflow/react");
@@ -36,6 +46,7 @@ vi.mock("@xyflow/react", async () => {
getNodes: () => reactFlowStateRef.current.nodes, getNodes: () => reactFlowStateRef.current.nodes,
getEdges: () => reactFlowStateRef.current.edges, getEdges: () => reactFlowStateRef.current.edges,
}), }),
useConnection: () => connectionStateRef.current,
}; };
}); });
@@ -93,6 +104,7 @@ describe("CustomConnectionLine", () => {
function renderLine(args?: { function renderLine(args?: {
withMagnetHandle?: boolean; withMagnetHandle?: boolean;
connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
omitFromHandleType?: boolean;
}) { }) {
document document
.querySelectorAll("[data-testid='custom-line-magnet-handle']") .querySelectorAll("[data-testid='custom-line-magnet-handle']")
@@ -106,6 +118,10 @@ describe("CustomConnectionLine", () => {
edges: [], edges: [],
}; };
connectionStateRef.current = {
fromHandle: { type: "source" },
};
if (args?.withMagnetHandle && container) { if (args?.withMagnetHandle && container) {
const handleEl = document.createElement("div"); const handleEl = document.createElement("div");
handleEl.setAttribute("data-testid", "custom-line-magnet-handle"); handleEl.setAttribute("data-testid", "custom-line-magnet-handle");
@@ -128,11 +144,19 @@ describe("CustomConnectionLine", () => {
} }
act(() => { act(() => {
const lineProps = {
...baseProps,
fromHandle: {
...baseProps.fromHandle,
...(args?.omitFromHandleType ? { type: undefined } : null),
},
} as ConnectionLineComponentProps;
root?.render( root?.render(
<CanvasConnectionMagnetismProvider> <CanvasConnectionMagnetismProvider>
<svg> <svg>
<CustomConnectionLine <CustomConnectionLine
{...baseProps} {...lineProps}
connectionStatus={args?.connectionStatus ?? "valid"} connectionStatus={args?.connectionStatus ?? "valid"}
/> />
</svg> </svg>
@@ -170,6 +194,17 @@ describe("CustomConnectionLine", () => {
expect(path.getAttribute("d")).toContain("220"); 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", () => { it("strengthens stroke visual feedback while snapped", () => {
renderLine(); renderLine();
const idlePath = getPath(); const idlePath = getPath();

View File

@@ -34,10 +34,26 @@ export default function CanvasHandle({
const connection = useConnection(); const connection = useConnection();
const { activeTarget } = useCanvasConnectionMagnetism(); const { activeTarget } = useCanvasConnectionMagnetism();
const connectionState = connection as {
inProgress?: boolean;
fromNode?: unknown;
toNode?: unknown;
fromHandle?: unknown;
toHandle?: unknown;
};
const hasConnectionPayload =
connectionState.fromNode !== undefined ||
connectionState.toNode !== undefined ||
connectionState.fromHandle !== undefined ||
connectionState.toHandle !== undefined;
const isConnectionDragActive =
connectionState.inProgress === true ||
(connectionState.inProgress === undefined && hasConnectionPayload);
const handleId = normalizeHandleId(id); const handleId = normalizeHandleId(id);
const targetHandleId = normalizeHandleId(activeTarget?.handleId); const targetHandleId = normalizeHandleId(activeTarget?.handleId);
const isActiveTarget = const isActiveTarget =
connection.inProgress && isConnectionDragActive &&
activeTarget !== null && activeTarget !== null &&
activeTarget.nodeId === nodeId && activeTarget.nodeId === nodeId &&
activeTarget.handleType === type && activeTarget.handleType === type &&

View File

@@ -7,6 +7,7 @@ import {
getSmoothStepPath, getSmoothStepPath,
getStraightPath, getStraightPath,
type ConnectionLineComponentProps, type ConnectionLineComponentProps,
useConnection,
useReactFlow, useReactFlow,
} from "@xyflow/react"; } from "@xyflow/react";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
@@ -52,14 +53,20 @@ export default function CustomConnectionLine({
connectionStatus, connectionStatus,
}: ConnectionLineComponentProps) { }: ConnectionLineComponentProps) {
const { getNodes, getEdges } = useReactFlow(); const { getNodes, getEdges } = useReactFlow();
const connection = useConnection();
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const fromHandleId = fromHandle?.id; const fromHandleId = fromHandle?.id;
const fromNodeId = fromNode?.id; const fromNodeId = fromNode?.id;
const connectionFromHandleType =
connection.fromHandle?.type === "source" || connection.fromHandle?.type === "target"
? connection.fromHandle.type
: null;
const fromHandleType = const fromHandleType =
fromHandle?.type === "source" || fromHandle?.type === "target" fromHandle?.type === "source" || fromHandle?.type === "target"
? fromHandle.type ? fromHandle.type
: null; : connectionFromHandleType ?? "source";
const resolvedMagnetTarget = useMemo(() => { const resolvedMagnetTarget = useMemo(() => {
if (!fromHandleType || !fromNodeId) { if (!fromHandleType || !fromNodeId) {