diff --git a/components/canvas/__tests__/canvas-handle.test.tsx b/components/canvas/__tests__/canvas-handle.test.tsx new file mode 100644 index 0000000..972c47d --- /dev/null +++ b/components/canvas/__tests__/canvas-handle.test.tsx @@ -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 & { + className?: string; + style?: React.CSSProperties; + }) =>
, + 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>; + }) { + connectionStateRef.current = { inProgress: args?.inProgress ?? false }; + + await act(async () => { + root?.render( + + + + , + ); + }); + } + + 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"); + }); +}); diff --git a/components/canvas/canvas-handle.tsx b/components/canvas/canvas-handle.tsx new file mode 100644 index 0000000..82265a5 --- /dev/null +++ b/components/canvas/canvas-handle.tsx @@ -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; + +type CanvasHandleProps = Omit & { + 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 ( + + ); +} diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index e1f0a21..6c23c06 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -179,6 +179,27 @@ export function canvasHandleAccentRgb(args: { 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). */ diff --git a/vitest.config.ts b/vitest.config.ts index 80ab732..f5af42b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ "components/canvas/__tests__/use-canvas-edge-types.test.tsx", "components/canvas/__tests__/use-canvas-node-interactions.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__/base-node-wrapper.test.tsx", "components/canvas/__tests__/use-node-local-data.test.tsx",