feat(canvas): add shared glowing canvas handle
This commit is contained in:
215
components/canvas/__tests__/canvas-handle.test.tsx
Normal file
215
components/canvas/__tests__/canvas-handle.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
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 {
|
||||||
|
CanvasConnectionMagnetismProvider,
|
||||||
|
useCanvasConnectionMagnetism,
|
||||||
|
} from "@/components/canvas/canvas-connection-magnetism-context";
|
||||||
|
|
||||||
|
const connectionStateRef: { current: { inProgress: boolean } } = {
|
||||||
|
current: { inProgress: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("@xyflow/react", () => ({
|
||||||
|
Handle: ({
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => <div className={className} style={style} {...props} />,
|
||||||
|
Position: { Left: "left", Right: "right" },
|
||||||
|
useConnection: () => connectionStateRef.current,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import CanvasHandle from "@/components/canvas/canvas-handle";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
function MagnetTargetSetter({
|
||||||
|
target,
|
||||||
|
}: {
|
||||||
|
target:
|
||||||
|
| {
|
||||||
|
nodeId: string;
|
||||||
|
handleId?: string;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
distancePx: number;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
}) {
|
||||||
|
const { setActiveTarget } = useCanvasConnectionMagnetism();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTarget(target);
|
||||||
|
}, [setActiveTarget, target]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CanvasHandle", () => {
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
connectionStateRef.current = { inProgress: false };
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renderHandle(args?: {
|
||||||
|
inProgress?: boolean;
|
||||||
|
activeTarget?: {
|
||||||
|
nodeId: string;
|
||||||
|
handleId?: string;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
centerX: number;
|
||||||
|
centerY: number;
|
||||||
|
distancePx: number;
|
||||||
|
} | null;
|
||||||
|
props?: Partial<React.ComponentProps<typeof CanvasHandle>>;
|
||||||
|
}) {
|
||||||
|
connectionStateRef.current = { inProgress: args?.inProgress ?? false };
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<CanvasConnectionMagnetismProvider>
|
||||||
|
<MagnetTargetSetter target={args?.activeTarget ?? null} />
|
||||||
|
<CanvasHandle
|
||||||
|
nodeId="node-1"
|
||||||
|
nodeType="image"
|
||||||
|
type="target"
|
||||||
|
position="left"
|
||||||
|
id="image-in"
|
||||||
|
{...args?.props}
|
||||||
|
/>
|
||||||
|
</CanvasConnectionMagnetismProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHandleElement() {
|
||||||
|
const handle = container?.querySelector("[data-node-id='node-1'][data-handle-type]");
|
||||||
|
if (!(handle instanceof HTMLElement)) {
|
||||||
|
throw new Error("CanvasHandle element not found");
|
||||||
|
}
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders default handle chrome with expected size and border", async () => {
|
||||||
|
await renderHandle();
|
||||||
|
|
||||||
|
const handle = getHandleElement();
|
||||||
|
expect(handle.className).toContain("!h-3");
|
||||||
|
expect(handle.className).toContain("!w-3");
|
||||||
|
expect(handle.className).toContain("!border-2");
|
||||||
|
expect(handle.className).toContain("!border-background");
|
||||||
|
expect(handle.getAttribute("data-glow-state")).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns on near-target glow when this handle is active target", async () => {
|
||||||
|
await renderHandle({
|
||||||
|
inProgress: true,
|
||||||
|
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("renders a stronger glow in snapped state than near state", async () => {
|
||||||
|
await renderHandle({
|
||||||
|
inProgress: true,
|
||||||
|
activeTarget: {
|
||||||
|
nodeId: "node-1",
|
||||||
|
handleId: "image-in",
|
||||||
|
handleType: "target",
|
||||||
|
centerX: 120,
|
||||||
|
centerY: 80,
|
||||||
|
distancePx: HANDLE_SNAP_RADIUS_PX + 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nearHandle = getHandleElement();
|
||||||
|
const nearGlow = nearHandle.style.boxShadow;
|
||||||
|
|
||||||
|
await renderHandle({
|
||||||
|
inProgress: true,
|
||||||
|
activeTarget: {
|
||||||
|
nodeId: "node-1",
|
||||||
|
handleId: "image-in",
|
||||||
|
handleType: "target",
|
||||||
|
centerX: 120,
|
||||||
|
centerY: 80,
|
||||||
|
distancePx: HANDLE_SNAP_RADIUS_PX - 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const snappedHandle = getHandleElement();
|
||||||
|
expect(snappedHandle.getAttribute("data-glow-state")).toBe("snapped");
|
||||||
|
expect(snappedHandle.style.boxShadow).not.toBe(nearGlow);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not glow for non-target handles during the same drag", async () => {
|
||||||
|
await renderHandle({
|
||||||
|
inProgress: true,
|
||||||
|
activeTarget: {
|
||||||
|
nodeId: "other-node",
|
||||||
|
handleId: "image-in",
|
||||||
|
handleType: "target",
|
||||||
|
centerX: 120,
|
||||||
|
centerY: 80,
|
||||||
|
distancePx: HANDLE_SNAP_RADIUS_PX - 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = getHandleElement();
|
||||||
|
expect(handle.getAttribute("data-glow-state")).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits stable handle geometry data attributes", async () => {
|
||||||
|
await renderHandle({
|
||||||
|
props: {
|
||||||
|
nodeId: "node-2",
|
||||||
|
id: undefined,
|
||||||
|
type: "source",
|
||||||
|
position: "right",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handle = container?.querySelector("[data-node-id='node-2'][data-handle-type='source']");
|
||||||
|
if (!(handle instanceof HTMLElement)) {
|
||||||
|
throw new Error("CanvasHandle source element not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(handle.getAttribute("data-node-id")).toBe("node-2");
|
||||||
|
expect(handle.getAttribute("data-handle-id")).toBe("");
|
||||||
|
expect(handle.getAttribute("data-handle-type")).toBe("source");
|
||||||
|
});
|
||||||
|
});
|
||||||
85
components/canvas/canvas-handle.tsx
Normal file
85
components/canvas/canvas-handle.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Handle, useConnection } from "@xyflow/react";
|
||||||
|
|
||||||
|
import { HANDLE_SNAP_RADIUS_PX } from "@/components/canvas/canvas-connection-magnetism";
|
||||||
|
import { useCanvasConnectionMagnetism } from "@/components/canvas/canvas-connection-magnetism-context";
|
||||||
|
import {
|
||||||
|
canvasHandleAccentColor,
|
||||||
|
canvasHandleAccentColorWithAlpha,
|
||||||
|
} from "@/lib/canvas-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ReactFlowHandleProps = React.ComponentProps<typeof Handle>;
|
||||||
|
|
||||||
|
type CanvasHandleProps = Omit<ReactFlowHandleProps, "id"> & {
|
||||||
|
nodeId: string;
|
||||||
|
nodeType?: string;
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeHandleId(value: string | undefined): string | undefined {
|
||||||
|
return value === "" ? undefined : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CanvasHandle({
|
||||||
|
nodeId,
|
||||||
|
nodeType,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
...rest
|
||||||
|
}: CanvasHandleProps) {
|
||||||
|
const connection = useConnection();
|
||||||
|
const { activeTarget } = useCanvasConnectionMagnetism();
|
||||||
|
|
||||||
|
const handleId = normalizeHandleId(id);
|
||||||
|
const targetHandleId = normalizeHandleId(activeTarget?.handleId);
|
||||||
|
const isActiveTarget =
|
||||||
|
connection.inProgress &&
|
||||||
|
activeTarget !== null &&
|
||||||
|
activeTarget.nodeId === nodeId &&
|
||||||
|
activeTarget.handleType === type &&
|
||||||
|
targetHandleId === handleId;
|
||||||
|
|
||||||
|
const glowState: "idle" | "near" | "snapped" = isActiveTarget
|
||||||
|
? activeTarget.distancePx <= HANDLE_SNAP_RADIUS_PX
|
||||||
|
? "snapped"
|
||||||
|
: "near"
|
||||||
|
: "idle";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...rest}
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"!h-3 !w-3 !border-2 !border-background transition-[box-shadow,background-color] duration-150",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
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-handle-id={id ?? ""}
|
||||||
|
data-handle-type={type}
|
||||||
|
data-glow-state={glowState}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -179,6 +179,27 @@ export function canvasHandleAccentRgb(args: {
|
|||||||
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
return SOURCE_NODE_GLOW_RGB[nodeType] ?? CONNECTION_LINE_FALLBACK_RGB;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canvasHandleAccentColor(args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
}): string {
|
||||||
|
const [r, g, b] = canvasHandleAccentRgb(args);
|
||||||
|
return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasHandleAccentColorWithAlpha(
|
||||||
|
args: {
|
||||||
|
nodeType: string | undefined;
|
||||||
|
handleId?: string | null;
|
||||||
|
handleType: "source" | "target";
|
||||||
|
},
|
||||||
|
alpha: number,
|
||||||
|
): string {
|
||||||
|
const [r, g, b] = canvasHandleAccentRgb(args);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
|||||||
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
"components/canvas/__tests__/use-canvas-edge-types.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
"components/canvas/__tests__/use-canvas-node-interactions.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-delete-handlers.test.tsx",
|
"components/canvas/__tests__/canvas-delete-handlers.test.tsx",
|
||||||
|
"components/canvas/__tests__/canvas-handle.test.tsx",
|
||||||
"components/canvas/__tests__/canvas-media-utils.test.ts",
|
"components/canvas/__tests__/canvas-media-utils.test.ts",
|
||||||
"components/canvas/__tests__/base-node-wrapper.test.tsx",
|
"components/canvas/__tests__/base-node-wrapper.test.tsx",
|
||||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user