From 079bc34ce43582d06001ba1641123e682a25a3f8 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 09:20:39 +0200 Subject: [PATCH] fix(canvas): restore visible handle glow during drag --- .../canvas/__tests__/canvas-handle.test.tsx | 48 +++++++++++++++---- .../__tests__/custom-connection-line.test.tsx | 37 +++++++++++++- components/canvas/canvas-handle.tsx | 18 ++++++- components/canvas/custom-connection-line.tsx | 9 +++- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/components/canvas/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx index 972c47d..62c8c3a 100644 --- a/components/canvas/__tests__/canvas-handle.test.tsx +++ b/components/canvas/__tests__/canvas-handle.test.tsx @@ -10,7 +10,13 @@ import { useCanvasConnectionMagnetism, } 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 }, }; @@ -77,7 +83,11 @@ describe("CanvasHandle", () => { }); async function renderHandle(args?: { - inProgress?: boolean; + connectionState?: { + inProgress?: boolean; + fromNode?: { id: string }; + fromHandle?: { id?: string; type?: "source" | "target" }; + }; activeTarget?: { nodeId: string; handleId?: string; @@ -88,7 +98,7 @@ describe("CanvasHandle", () => { } | null; props?: Partial>; }) { - connectionStateRef.current = { inProgress: args?.inProgress ?? false }; + connectionStateRef.current = args?.connectionState ?? { inProgress: false }; await act(async () => { root?.render( @@ -98,7 +108,7 @@ describe("CanvasHandle", () => { nodeId="node-1" nodeType="image" type="target" - position="left" + position={"left" as React.ComponentProps["position"]} id="image-in" {...args?.props} /> @@ -128,7 +138,7 @@ describe("CanvasHandle", () => { it("turns on near-target glow when this handle is active target", async () => { await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "node-1", handleId: "image-in", @@ -145,7 +155,7 @@ describe("CanvasHandle", () => { it("renders a stronger glow in snapped state than near state", async () => { await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "node-1", handleId: "image-in", @@ -160,7 +170,7 @@ describe("CanvasHandle", () => { const nearGlow = nearHandle.style.boxShadow; await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "node-1", handleId: "image-in", @@ -178,7 +188,7 @@ describe("CanvasHandle", () => { it("does not glow for non-target handles during the same drag", async () => { await renderHandle({ - inProgress: true, + connectionState: { inProgress: true }, activeTarget: { nodeId: "other-node", handleId: "image-in", @@ -193,13 +203,33 @@ describe("CanvasHandle", () => { 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 () => { await renderHandle({ props: { nodeId: "node-2", id: undefined, type: "source", - position: "right", + position: "right" as React.ComponentProps["position"], }, }); diff --git a/components/canvas/__tests__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx index 6e8c16e..379b438 100644 --- a/components/canvas/__tests__/custom-connection-line.test.tsx +++ b/components/canvas/__tests__/custom-connection-line.test.tsx @@ -27,6 +27,16 @@ const reactFlowStateRef: { }, }; +const connectionStateRef: { + current: { + fromHandle?: { type?: "source" | "target" }; + }; +} = { + current: { + fromHandle: { type: "source" }, + }, +}; + vi.mock("@xyflow/react", async () => { const actual = await vi.importActual("@xyflow/react"); @@ -36,6 +46,7 @@ vi.mock("@xyflow/react", async () => { getNodes: () => reactFlowStateRef.current.nodes, getEdges: () => reactFlowStateRef.current.edges, }), + useConnection: () => connectionStateRef.current, }; }); @@ -93,6 +104,7 @@ describe("CustomConnectionLine", () => { function renderLine(args?: { withMagnetHandle?: boolean; connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; + omitFromHandleType?: boolean; }) { document .querySelectorAll("[data-testid='custom-line-magnet-handle']") @@ -106,6 +118,10 @@ describe("CustomConnectionLine", () => { edges: [], }; + connectionStateRef.current = { + fromHandle: { type: "source" }, + }; + if (args?.withMagnetHandle && container) { const handleEl = document.createElement("div"); handleEl.setAttribute("data-testid", "custom-line-magnet-handle"); @@ -128,11 +144,19 @@ describe("CustomConnectionLine", () => { } act(() => { + const lineProps = { + ...baseProps, + fromHandle: { + ...baseProps.fromHandle, + ...(args?.omitFromHandleType ? { type: undefined } : null), + }, + } as ConnectionLineComponentProps; + root?.render( @@ -170,6 +194,17 @@ describe("CustomConnectionLine", () => { 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", () => { renderLine(); const idlePath = getPath(); diff --git a/components/canvas/canvas-handle.tsx b/components/canvas/canvas-handle.tsx index 82265a5..bc03931 100644 --- a/components/canvas/canvas-handle.tsx +++ b/components/canvas/canvas-handle.tsx @@ -34,10 +34,26 @@ export default function CanvasHandle({ const connection = useConnection(); 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 targetHandleId = normalizeHandleId(activeTarget?.handleId); const isActiveTarget = - connection.inProgress && + isConnectionDragActive && activeTarget !== null && activeTarget.nodeId === nodeId && activeTarget.handleType === type && diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index 51bcbe7..8dbf683 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -7,6 +7,7 @@ import { getSmoothStepPath, getStraightPath, type ConnectionLineComponentProps, + useConnection, useReactFlow, } from "@xyflow/react"; import { useEffect, useMemo } from "react"; @@ -52,14 +53,20 @@ export default function CustomConnectionLine({ connectionStatus, }: ConnectionLineComponentProps) { const { getNodes, getEdges } = useReactFlow(); + const connection = useConnection(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const fromHandleId = fromHandle?.id; const fromNodeId = fromNode?.id; + const connectionFromHandleType = + connection.fromHandle?.type === "source" || connection.fromHandle?.type === "target" + ? connection.fromHandle.type + : null; + const fromHandleType = fromHandle?.type === "source" || fromHandle?.type === "target" ? fromHandle.type - : null; + : connectionFromHandleType ?? "source"; const resolvedMagnetTarget = useMemo(() => { if (!fromHandleType || !fromNodeId) {