From 22d0187c66fb2f9b42a6027c1f5b13e5f5f31d3e Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 10:46:43 +0200 Subject: [PATCH] fix(canvas): strengthen pre-snap glow and reconnect drag UX --- app/globals.css | 5 + .../canvas/__tests__/canvas-handle.test.tsx | 103 +++++++++++++++++- .../__tests__/custom-connection-line.test.tsx | 88 ++++++++++++++- .../canvas/canvas-connection-magnetism.ts | 46 ++++++++ components/canvas/canvas-handle.tsx | 75 ++++++++++--- components/canvas/canvas.tsx | 4 + components/canvas/custom-connection-line.tsx | 47 ++++++-- lib/canvas-utils.ts | 78 +++++++++++++ 8 files changed, 415 insertions(+), 31 deletions(-) diff --git a/app/globals.css b/app/globals.css index f2735b5..48156a5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -190,6 +190,11 @@ z-index: 50; } + /* Reconnect-Anker immer pointer-interactive halten (Drag-Detach/Reconnect) */ + .react-flow__edgeupdater { + pointer-events: all; + } + /* Proximity-Vorschaukante (temp) */ .react-flow__edge.temp { opacity: 0.9; diff --git a/components/canvas/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx index 62c8c3a..da8033b 100644 --- a/components/canvas/__tests__/canvas-handle.test.tsx +++ b/components/canvas/__tests__/canvas-handle.test.tsx @@ -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: { diff --git a/components/canvas/__tests__/custom-connection-line.test.tsx b/components/canvas/__tests__/custom-connection-line.test.tsx index 379b438..bb583ce 100644 --- a/components/canvas/__tests__/custom-connection-line.test.tsx +++ b/components/canvas/__tests__/custom-connection-line.test.tsx @@ -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); + }); }); diff --git a/components/canvas/canvas-connection-magnetism.ts b/components/canvas/canvas-connection-magnetism.ts index 468049b..949b3f7 100644 --- a/components/canvas/canvas-connection-magnetism.ts +++ b/components/canvas/canvas-connection-magnetism.ts @@ -5,6 +5,52 @@ import { validateCanvasConnectionPolicy } from "@/lib/canvas-connection-policy"; export const HANDLE_GLOW_RADIUS_PX = 56; export const HANDLE_SNAP_RADIUS_PX = 40; +function clamp01(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + if (value <= 0) { + return 0; + } + if (value >= 1) { + return 1; + } + return value; +} + +function smoothstep(value: number): number { + const v = clamp01(value); + return v * v * (3 - 2 * v); +} + +export function resolveCanvasGlowStrength(args: { + distancePx: number; + glowRadiusPx?: number; + snapRadiusPx?: number; +}): number { + const glowRadius = args.glowRadiusPx ?? HANDLE_GLOW_RADIUS_PX; + const snapRadius = args.snapRadiusPx ?? HANDLE_SNAP_RADIUS_PX; + + if (!Number.isFinite(args.distancePx)) { + return 0; + } + if (args.distancePx <= 0) { + return 1; + } + if (args.distancePx >= glowRadius) { + return 0; + } + if (args.distancePx <= snapRadius) { + return 1; + } + + const preSnapRange = Math.max(1, glowRadius - snapRadius); + const progressToSnap = (glowRadius - args.distancePx) / preSnapRange; + const eased = smoothstep(progressToSnap); + + return 0.22 + eased * 0.68; +} + export type CanvasMagnetTarget = { nodeId: string; handleId?: string; diff --git a/components/canvas/canvas-handle.tsx b/components/canvas/canvas-handle.tsx index bc03931..0fbd7b7 100644 --- a/components/canvas/canvas-handle.tsx +++ b/components/canvas/canvas-handle.tsx @@ -2,11 +2,14 @@ import { Handle, useConnection } from "@xyflow/react"; -import { HANDLE_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism"; +import { + resolveCanvasGlowStrength, +} from "@/components/canvas/canvas-connection-magnetism"; import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; import { canvasHandleAccentColor, - canvasHandleAccentColorWithAlpha, + canvasHandleGlowShadow, + type EdgeGlowColorMode, } from "@/lib/canvas-utils"; import { cn } from "@/lib/utils"; @@ -36,6 +39,7 @@ export default function CanvasHandle({ const connectionState = connection as { inProgress?: boolean; + isValid?: boolean | null; fromNode?: unknown; toNode?: unknown; fromHandle?: unknown; @@ -52,6 +56,32 @@ export default function CanvasHandle({ const handleId = normalizeHandleId(id); const targetHandleId = normalizeHandleId(activeTarget?.handleId); + + const toNodeId = + connectionState.toNode && + typeof connectionState.toNode === "object" && + "id" in connectionState.toNode && + typeof (connectionState.toNode as { id?: unknown }).id === "string" + ? ((connectionState.toNode as { id: string }).id ?? null) + : null; + + const toHandleMeta = + connectionState.toHandle && typeof connectionState.toHandle === "object" + ? (connectionState.toHandle as { id?: string | null; type?: "source" | "target" }) + : null; + const toHandleId = normalizeHandleId( + toHandleMeta?.id === null ? undefined : toHandleMeta?.id, + ); + const toHandleType = + toHandleMeta?.type === "source" || toHandleMeta?.type === "target" + ? toHandleMeta.type + : null; + + const colorMode: EdgeGlowColorMode = + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + const isActiveTarget = isConnectionDragActive && activeTarget !== null && @@ -59,21 +89,37 @@ export default function CanvasHandle({ activeTarget.handleType === type && targetHandleId === handleId; - const glowState: "idle" | "near" | "snapped" = isActiveTarget - ? activeTarget.distancePx <= HANDLE_SNAP_RADIUS_PX - ? "snapped" - : "near" - : "idle"; + const isNativeHoverTarget = + connectionState.inProgress === true && + toNodeId === nodeId && + toHandleType === type && + toHandleId === handleId; + + let glowStrength = 0; + + if (isActiveTarget) { + glowStrength = resolveCanvasGlowStrength({ + distancePx: activeTarget.distancePx, + }); + } else if (isNativeHoverTarget) { + glowStrength = connectionState.isValid === true ? 1 : 0.68; + } + + const glowState: "idle" | "near" | "snapped" = + glowStrength <= 0 ? "idle" : glowStrength >= 0.96 ? "snapped" : "near"; const accentColor = canvasHandleAccentColor({ nodeType, handleId, handleType: type, }); - const glowAlpha = glowState === "snapped" ? 0.62 : glowState === "near" ? 0.4 : 0; - const ringAlpha = glowState === "snapped" ? 0.34 : glowState === "near" ? 0.2 : 0; - const glowSize = glowState === "snapped" ? 14 : glowState === "near" ? 10 : 0; - const ringSize = glowState === "snapped" ? 6 : glowState === "near" ? 4 : 0; + const boxShadow = canvasHandleGlowShadow({ + nodeType, + handleId, + handleType: type, + strength: glowStrength, + colorMode, + }); return ( ); } diff --git a/components/canvas/canvas.tsx b/components/canvas/canvas.tsx index 13ec262..6ab38ab 100644 --- a/components/canvas/canvas.tsx +++ b/components/canvas/canvas.tsx @@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; +import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism"; import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context"; interface CanvasInnerProps { @@ -676,6 +677,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) { panOnDrag={flowPanOnDrag} selectionOnDrag={flowSelectionOnDrag} panActivationKeyCode="Space" + connectionRadius={HANDLE_GLOW_RADIUS_PX} + reconnectRadius={24} + edgesReconnectable proOptions={{ hideAttribution: true }} colorMode={resolvedTheme === "dark" ? "dark" : "light"} className={cn( diff --git a/components/canvas/custom-connection-line.tsx b/components/canvas/custom-connection-line.tsx index 8dbf683..746ca5f 100644 --- a/components/canvas/custom-connection-line.tsx +++ b/components/canvas/custom-connection-line.tsx @@ -14,10 +14,15 @@ import { useEffect, useMemo } from "react"; import { HANDLE_SNAP_RADIUS_PX, + resolveCanvasGlowStrength, resolveCanvasMagnetTarget, } from "@/components/canvas/canvas-connection-magnetism"; import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; -import { connectionLineAccentRgb } from "@/lib/canvas-utils"; +import { + connectionLineAccentRgb, + connectionLineGlowFilter, + type EdgeGlowColorMode, +} from "@/lib/canvas-utils"; function hasSameMagnetTarget( a: Parameters["setActiveTarget"]>[0], @@ -51,8 +56,9 @@ export default function CustomConnectionLine({ fromPosition, toPosition, connectionStatus, + pointer, }: ConnectionLineComponentProps) { - const { getNodes, getEdges } = useReactFlow(); + const { getNodes, getEdges, screenToFlowPosition } = useReactFlow(); const connection = useConnection(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const fromHandleId = fromHandle?.id; @@ -73,15 +79,20 @@ export default function CustomConnectionLine({ return null; } + const magnetPoint = + pointer && Number.isFinite(pointer.x) && Number.isFinite(pointer.y) + ? { x: pointer.x, y: pointer.y } + : { x: toX, y: toY }; + return resolveCanvasMagnetTarget({ - point: { x: toX, y: toY }, + point: magnetPoint, fromNodeId, fromHandleId: fromHandleId ?? undefined, fromHandleType, nodes: getNodes(), edges: getEdges(), }); - }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, toX, toY]); + }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, pointer, toX, toY]); useEffect(() => { if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) { @@ -91,12 +102,21 @@ export default function CustomConnectionLine({ }, [activeTarget, resolvedMagnetTarget, setActiveTarget]); const magnetTarget = activeTarget ?? resolvedMagnetTarget; + const glowStrength = magnetTarget + ? resolveCanvasGlowStrength({ + distancePx: magnetTarget.distancePx, + }) + : 0; const snappedTarget = magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX ? magnetTarget : null; - const targetX = snappedTarget?.centerX ?? toX; - const targetY = snappedTarget?.centerY ?? toY; + const snappedFlowPoint = + snappedTarget === null + ? null + : screenToFlowPosition({ x: snappedTarget.centerX, y: snappedTarget.centerY }); + const targetX = snappedFlowPoint?.x ?? toX; + const targetY = snappedFlowPoint?.y ?? toY; const pathParams = { sourceX: fromX, @@ -130,10 +150,17 @@ export default function CustomConnectionLine({ const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId); 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; + const colorMode: EdgeGlowColorMode = + typeof document !== "undefined" && document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + const strokeWidth = 2.5 + glowStrength * 0.75; + const filter = connectionLineGlowFilter({ + nodeType: fromNode.type, + handleId: fromHandleId, + strength: glowStrength, + colorMode, + }); return ( = 1) { + return 1; + } + return value; +} + +function lerp(min: number, max: number, t: number): number { + return min + (max - min) * t; +} + +export function canvasHandleGlowShadow(args: { + nodeType: string | undefined; + handleId?: string | null; + handleType: "source" | "target"; + strength: number; + colorMode: EdgeGlowColorMode; +}): string | undefined { + const strength = clampUnit(args.strength); + if (strength <= 0) { + return undefined; + } + + const [r, g, b] = canvasHandleAccentRgb(args); + const isDark = args.colorMode === "dark"; + + const ringAlpha = isDark + ? lerp(0.08, 0.3, strength) + : lerp(0.06, 0.2, strength); + const glowAlpha = isDark + ? lerp(0.12, 0.58, strength) + : lerp(0.08, 0.34, strength); + const ringSize = isDark + ? lerp(1.8, 6.4, strength) + : lerp(1.5, 5.2, strength); + const glowSize = isDark + ? lerp(4.5, 15, strength) + : lerp(3.5, 12, strength); + + return `0 0 0 ${ringSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${ringAlpha.toFixed(3)}), 0 0 ${glowSize.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${glowAlpha.toFixed(3)})`; +} + +export function connectionLineGlowFilter(args: { + nodeType: string | undefined; + handleId: string | null | undefined; + strength: number; + colorMode: EdgeGlowColorMode; +}): string | undefined { + const strength = clampUnit(args.strength); + if (strength <= 0) { + return undefined; + } + + const [r, g, b] = connectionLineAccentRgb(args.nodeType, args.handleId); + const isDark = args.colorMode === "dark"; + + const innerAlpha = isDark + ? lerp(0.22, 0.72, strength) + : lerp(0.12, 0.42, strength); + const outerAlpha = isDark + ? lerp(0.12, 0.38, strength) + : lerp(0.06, 0.2, strength); + const innerBlur = isDark + ? lerp(2.4, 4.2, strength) + : lerp(2, 3.4, strength); + const outerBlur = isDark + ? lerp(5.4, 9.8, strength) + : lerp(4.6, 7.8, strength); + + return `drop-shadow(0 0 ${innerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${innerAlpha.toFixed(3)})) drop-shadow(0 0 ${outerBlur.toFixed(2)}px rgba(${r}, ${g}, ${b}, ${outerAlpha.toFixed(3)}))`; +} + /** * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect). */