fix(canvas): strengthen pre-snap glow and reconnect drag UX
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user