fix(canvas): strengthen pre-snap glow and reconnect drag UX

This commit is contained in:
2026-04-11 10:46:43 +02:00
parent 079bc34ce4
commit 22d0187c66
8 changed files with 415 additions and 31 deletions

View File

@@ -190,6 +190,11 @@
z-index: 50; z-index: 50;
} }
/* Reconnect-Anker immer pointer-interactive halten (Drag-Detach/Reconnect) */
.react-flow__edgeupdater {
pointer-events: all;
}
/* Proximity-Vorschaukante (temp) */ /* Proximity-Vorschaukante (temp) */
.react-flow__edge.temp { .react-flow__edge.temp {
opacity: 0.9; opacity: 0.9;

View File

@@ -4,7 +4,10 @@ import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 { import {
CanvasConnectionMagnetismProvider, CanvasConnectionMagnetismProvider,
useCanvasConnectionMagnetism, useCanvasConnectionMagnetism,
@@ -15,6 +18,9 @@ const connectionStateRef: {
inProgress?: boolean; inProgress?: boolean;
fromNode?: { id: string }; fromNode?: { id: string };
fromHandle?: { id?: string; type?: "source" | "target" }; fromHandle?: { id?: string; type?: "source" | "target" };
toNode?: { id: string } | null;
toHandle?: { id?: string | null; type?: "source" | "target" } | null;
isValid?: boolean | null;
}; };
} = { } = {
current: { inProgress: false }, current: { inProgress: false },
@@ -78,6 +84,7 @@ describe("CanvasHandle", () => {
}); });
} }
container?.remove(); container?.remove();
document.documentElement.classList.remove("dark");
container = null; container = null;
root = null; root = null;
}); });
@@ -87,6 +94,9 @@ describe("CanvasHandle", () => {
inProgress?: boolean; inProgress?: boolean;
fromNode?: { id: string }; fromNode?: { id: string };
fromHandle?: { id?: string; type?: "source" | "target" }; fromHandle?: { id?: string; type?: "source" | "target" };
toNode?: { id: string } | null;
toHandle?: { id?: string | null; type?: "source" | "target" } | null;
isValid?: boolean | null;
}; };
activeTarget?: { activeTarget?: {
nodeId: string; nodeId: string;
@@ -186,6 +196,42 @@ describe("CanvasHandle", () => {
expect(snappedHandle.style.boxShadow).not.toBe(nearGlow); 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 () => { it("does not glow for non-target handles during the same drag", async () => {
await renderHandle({ await renderHandle({
connectionState: { inProgress: true }, connectionState: { inProgress: true },
@@ -223,6 +269,61 @@ describe("CanvasHandle", () => {
expect(handle.getAttribute("data-glow-state")).toBe("near"); 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 () => { it("emits stable handle geometry data attributes", async () => {
await renderHandle({ await renderHandle({
props: { props: {

View File

@@ -19,11 +19,13 @@ const reactFlowStateRef: {
current: { current: {
nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>; nodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: object }>;
edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>; edges: Array<{ id: string; source: string; target: string; targetHandle?: string | null }>;
screenToFlowPosition: ({ x, y }: { x: number; y: number }) => { x: number; y: number };
}; };
} = { } = {
current: { current: {
nodes: [], nodes: [],
edges: [], edges: [],
screenToFlowPosition: ({ x, y }) => ({ x, y }),
}, },
}; };
@@ -45,6 +47,7 @@ vi.mock("@xyflow/react", async () => {
useReactFlow: () => ({ useReactFlow: () => ({
getNodes: () => reactFlowStateRef.current.nodes, getNodes: () => reactFlowStateRef.current.nodes,
getEdges: () => reactFlowStateRef.current.edges, getEdges: () => reactFlowStateRef.current.edges,
screenToFlowPosition: reactFlowStateRef.current.screenToFlowPosition,
}), }),
useConnection: () => connectionStateRef.current, useConnection: () => connectionStateRef.current,
}; };
@@ -97,6 +100,7 @@ describe("CustomConnectionLine", () => {
document document
.querySelectorAll("[data-testid='custom-line-magnet-handle']") .querySelectorAll("[data-testid='custom-line-magnet-handle']")
.forEach((element) => element.remove()); .forEach((element) => element.remove());
document.documentElement.classList.remove("dark");
container = null; container = null;
root = null; root = null;
}); });
@@ -105,6 +109,9 @@ describe("CustomConnectionLine", () => {
withMagnetHandle?: boolean; withMagnetHandle?: boolean;
connectionStatus?: ConnectionLineComponentProps["connectionStatus"]; connectionStatus?: ConnectionLineComponentProps["connectionStatus"];
omitFromHandleType?: boolean; omitFromHandleType?: boolean;
toX?: number;
toY?: number;
pointer?: { x: number; y: number };
}) { }) {
document document
.querySelectorAll("[data-testid='custom-line-magnet-handle']") .querySelectorAll("[data-testid='custom-line-magnet-handle']")
@@ -116,6 +123,7 @@ describe("CustomConnectionLine", () => {
{ id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} }, { id: "target-node", type: "render", position: { x: 0, y: 0 }, data: {} },
], ],
edges: [], edges: [],
screenToFlowPosition: ({ x, y }) => ({ x, y }),
}; };
connectionStateRef.current = { connectionStateRef.current = {
@@ -146,6 +154,9 @@ describe("CustomConnectionLine", () => {
act(() => { act(() => {
const lineProps = { const lineProps = {
...baseProps, ...baseProps,
...(args?.toX !== undefined ? { toX: args.toX } : null),
...(args?.toY !== undefined ? { toY: args.toY } : null),
...(args?.pointer ? { pointer: args.pointer } : null),
fromHandle: { fromHandle: {
...baseProps.fromHandle, ...baseProps.fromHandle,
...(args?.omitFromHandleType ? { type: undefined } : null), ...(args?.omitFromHandleType ? { type: undefined } : null),
@@ -220,6 +231,29 @@ describe("CustomConnectionLine", () => {
expect(snappedPath.style.filter).not.toBe(idleFilter); 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", () => { it("keeps invalid connection opacity behavior while snapped", () => {
renderLine({ renderLine({
withMagnetHandle: true, withMagnetHandle: true,
@@ -229,4 +263,48 @@ describe("CustomConnectionLine", () => {
const path = getPath(); const path = getPath();
expect(path.style.opacity).toBe("0.45"); 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);
});
}); });

View File

@@ -5,6 +5,52 @@ import { validateCanvasConnectionPolicy } from "@/lib/canvas-connection-policy";
export const HANDLE_GLOW_RADIUS_PX = 56; export const HANDLE_GLOW_RADIUS_PX = 56;
export const HANDLE_SNAP_RADIUS_PX = 40; 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 = { export type CanvasMagnetTarget = {
nodeId: string; nodeId: string;
handleId?: string; handleId?: string;

View File

@@ -2,11 +2,14 @@
import { Handle, useConnection } from "@xyflow/react"; 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 { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
import { import {
canvasHandleAccentColor, canvasHandleAccentColor,
canvasHandleAccentColorWithAlpha, canvasHandleGlowShadow,
type EdgeGlowColorMode,
} from "@/lib/canvas-utils"; } from "@/lib/canvas-utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -36,6 +39,7 @@ export default function CanvasHandle({
const connectionState = connection as { const connectionState = connection as {
inProgress?: boolean; inProgress?: boolean;
isValid?: boolean | null;
fromNode?: unknown; fromNode?: unknown;
toNode?: unknown; toNode?: unknown;
fromHandle?: unknown; fromHandle?: unknown;
@@ -52,6 +56,32 @@ export default function CanvasHandle({
const handleId = normalizeHandleId(id); const handleId = normalizeHandleId(id);
const targetHandleId = normalizeHandleId(activeTarget?.handleId); 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 = const isActiveTarget =
isConnectionDragActive && isConnectionDragActive &&
activeTarget !== null && activeTarget !== null &&
@@ -59,21 +89,37 @@ export default function CanvasHandle({
activeTarget.handleType === type && activeTarget.handleType === type &&
targetHandleId === handleId; targetHandleId === handleId;
const glowState: "idle" | "near" | "snapped" = isActiveTarget const isNativeHoverTarget =
? activeTarget.distancePx <= HANDLE_SNAP_RADIUS_PX connectionState.inProgress === true &&
? "snapped" toNodeId === nodeId &&
: "near" toHandleType === type &&
: "idle"; 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({ const accentColor = canvasHandleAccentColor({
nodeType, nodeType,
handleId, handleId,
handleType: type, handleType: type,
}); });
const glowAlpha = glowState === "snapped" ? 0.62 : glowState === "near" ? 0.4 : 0; const boxShadow = canvasHandleGlowShadow({
const ringAlpha = glowState === "snapped" ? 0.34 : glowState === "near" ? 0.2 : 0; nodeType,
const glowSize = glowState === "snapped" ? 14 : glowState === "near" ? 10 : 0; handleId,
const ringSize = glowState === "snapped" ? 6 : glowState === "near" ? 4 : 0; handleType: type,
strength: glowStrength,
colorMode,
});
return ( return (
<Handle <Handle
@@ -87,15 +133,14 @@ export default function CanvasHandle({
style={{ style={{
...style, ...style,
backgroundColor: accentColor, backgroundColor: accentColor,
boxShadow: boxShadow,
glowState === "idle"
? undefined
: `0 0 0 ${ringSize}px ${canvasHandleAccentColorWithAlpha({ nodeType, handleId, handleType: type }, ringAlpha)}, 0 0 ${glowSize}px ${canvasHandleAccentColorWithAlpha({ nodeType, handleId, handleType: type }, glowAlpha)}`,
}} }}
data-node-id={nodeId} data-node-id={nodeId}
data-handle-id={id ?? ""} data-handle-id={id ?? ""}
data-handle-type={type} data-handle-type={type}
data-glow-state={glowState} data-glow-state={glowState}
data-glow-strength={glowStrength.toFixed(3)}
data-glow-mode={colorMode}
/> />
); );
} }

View File

@@ -78,6 +78,7 @@ import { useCanvasEdgeTypes } from "./use-canvas-edge-types";
import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation"; import { useCanvasFlowReconciliation } from "./use-canvas-flow-reconciliation";
import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence"; import { useCanvasLocalSnapshotPersistence } from "./use-canvas-local-snapshot-persistence";
import { useCanvasSyncEngine } from "./use-canvas-sync-engine"; import { useCanvasSyncEngine } from "./use-canvas-sync-engine";
import { HANDLE_GLOW_RADIUS_PX } from "./canvas-connection-magnetism";
import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context"; import { CanvasConnectionMagnetismProvider } from "./canvas-connection-magnetism-context";
interface CanvasInnerProps { interface CanvasInnerProps {
@@ -676,6 +677,9 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
panOnDrag={flowPanOnDrag} panOnDrag={flowPanOnDrag}
selectionOnDrag={flowSelectionOnDrag} selectionOnDrag={flowSelectionOnDrag}
panActivationKeyCode="Space" panActivationKeyCode="Space"
connectionRadius={HANDLE_GLOW_RADIUS_PX}
reconnectRadius={24}
edgesReconnectable
proOptions={{ hideAttribution: true }} proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"} colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className={cn( className={cn(

View File

@@ -14,10 +14,15 @@ import { useEffect, useMemo } from "react";
import { import {
HANDLE_SNAP_RADIUS_PX, HANDLE_SNAP_RADIUS_PX,
resolveCanvasGlowStrength,
resolveCanvasMagnetTarget, resolveCanvasMagnetTarget,
} from "@/components/canvas/canvas-connection-magnetism"; } from "@/components/canvas/canvas-connection-magnetism";
import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context"; 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( function hasSameMagnetTarget(
a: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0], a: Parameters<ReturnType<typeof useCanvasConnectionMagnetism>["setActiveTarget"]>[0],
@@ -51,8 +56,9 @@ export default function CustomConnectionLine({
fromPosition, fromPosition,
toPosition, toPosition,
connectionStatus, connectionStatus,
pointer,
}: ConnectionLineComponentProps) { }: ConnectionLineComponentProps) {
const { getNodes, getEdges } = useReactFlow(); const { getNodes, getEdges, screenToFlowPosition } = useReactFlow();
const connection = useConnection(); const connection = useConnection();
const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism(); const { activeTarget, setActiveTarget } = useCanvasConnectionMagnetism();
const fromHandleId = fromHandle?.id; const fromHandleId = fromHandle?.id;
@@ -73,15 +79,20 @@ export default function CustomConnectionLine({
return null; 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({ return resolveCanvasMagnetTarget({
point: { x: toX, y: toY }, point: magnetPoint,
fromNodeId, fromNodeId,
fromHandleId: fromHandleId ?? undefined, fromHandleId: fromHandleId ?? undefined,
fromHandleType, fromHandleType,
nodes: getNodes(), nodes: getNodes(),
edges: getEdges(), edges: getEdges(),
}); });
}, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, toX, toY]); }, [fromHandleId, fromHandleType, fromNodeId, getEdges, getNodes, pointer, toX, toY]);
useEffect(() => { useEffect(() => {
if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) { if (hasSameMagnetTarget(activeTarget, resolvedMagnetTarget)) {
@@ -91,12 +102,21 @@ export default function CustomConnectionLine({
}, [activeTarget, resolvedMagnetTarget, setActiveTarget]); }, [activeTarget, resolvedMagnetTarget, setActiveTarget]);
const magnetTarget = activeTarget ?? resolvedMagnetTarget; const magnetTarget = activeTarget ?? resolvedMagnetTarget;
const glowStrength = magnetTarget
? resolveCanvasGlowStrength({
distancePx: magnetTarget.distancePx,
})
: 0;
const snappedTarget = const snappedTarget =
magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX magnetTarget && magnetTarget.distancePx <= HANDLE_SNAP_RADIUS_PX
? magnetTarget ? magnetTarget
: null; : null;
const targetX = snappedTarget?.centerX ?? toX; const snappedFlowPoint =
const targetY = snappedTarget?.centerY ?? toY; snappedTarget === null
? null
: screenToFlowPosition({ x: snappedTarget.centerX, y: snappedTarget.centerY });
const targetX = snappedFlowPoint?.x ?? toX;
const targetY = snappedFlowPoint?.y ?? toY;
const pathParams = { const pathParams = {
sourceX: fromX, sourceX: fromX,
@@ -130,10 +150,17 @@ export default function CustomConnectionLine({
const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId); const [r, g, b] = connectionLineAccentRgb(fromNode.type, fromHandleId);
const opacity = connectionStatus === "invalid" ? 0.45 : 1; const opacity = connectionStatus === "invalid" ? 0.45 : 1;
const strokeWidth = snappedTarget ? 3.25 : 2.5; const colorMode: EdgeGlowColorMode =
const filter = snappedTarget typeof document !== "undefined" && document.documentElement.classList.contains("dark")
? `drop-shadow(0 0 3px rgba(${r}, ${g}, ${b}, 0.7)) drop-shadow(0 0 8px rgba(${r}, ${g}, ${b}, 0.48))` ? "dark"
: undefined; : "light";
const strokeWidth = 2.5 + glowStrength * 0.75;
const filter = connectionLineGlowFilter({
nodeType: fromNode.type,
handleId: fromHandleId,
strength: glowStrength,
colorMode,
});
return ( return (
<path <path

View File

@@ -200,6 +200,84 @@ export function canvasHandleAccentColorWithAlpha(
return `rgba(${r}, ${g}, ${b}, ${alpha})`; return `rgba(${r}, ${g}, ${b}, ${alpha})`;
} }
function clampUnit(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
if (value <= 0) {
return 0;
}
if (value >= 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). * RGB für die temporäre Verbindungslinie (Quell-Node + optional Handle, z. B. Reconnect).
*/ */