From cda97f614b0c092b9089f821c19ab3e119604635 Mon Sep 17 00:00:00 2001 From: Matthias Meister Date: Sat, 11 Apr 2026 10:31:51 +0200 Subject: [PATCH] feat(canvas): allow mixer renders and improve edge insert visibility --- .../canvas/__tests__/default-edge.test.tsx | 38 +++++++++++++++++++ components/canvas/edges/default-edge.tsx | 25 ++++++++++-- lib/canvas-connection-policy.ts | 3 +- tests/canvas-connection-policy.test.ts | 10 +++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/components/canvas/__tests__/default-edge.test.tsx b/components/canvas/__tests__/default-edge.test.tsx index 82d4976..bb9e866 100644 --- a/components/canvas/__tests__/default-edge.test.tsx +++ b/components/canvas/__tests__/default-edge.test.tsx @@ -8,6 +8,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import DefaultEdge from "@/components/canvas/edges/default-edge"; +const mockViewport = { + x: 0, + y: 0, + zoom: 1, +}; + vi.mock("@xyflow/react", async () => { const actual = await vi.importActual( "@xyflow/react", @@ -18,6 +24,7 @@ vi.mock("@xyflow/react", async () => { EdgeLabelRenderer: ({ children }: { children: ReactNode }) => ( {children} ), + useViewport: () => mockViewport, }; }); @@ -95,6 +102,8 @@ describe("DefaultEdge", () => { let container: HTMLDivElement | null = null; afterEach(() => { + mockViewport.zoom = 1; + if (root) { act(() => { root?.unmount(); @@ -185,4 +194,33 @@ describe("DefaultEdge", () => { expect(edgePath).not.toBeNull(); expect(edgePath?.getAttribute("d")).toBeTruthy(); }); + + it("applies zoom-aware scaling with clamps to keep the plus legible", () => { + const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>(); + + mockViewport.zoom = 0.2; + ({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true })); + const insertButton = getInsertButton(container); + expect(insertButton.style.transform).toContain("scale(2.2)"); + + mockViewport.zoom = 4; + act(() => { + root?.render( + + + , + ); + }); + expect(insertButton.style.transform).toContain("scale(0.95)"); + }); + + it("uses stronger visual styling for distant zoom visibility", () => { + const onInsertClick = vi.fn<(anchor: EdgeInsertAnchor) => void>(); + ({ container, root } = renderEdge({ onInsertClick, isMenuOpen: true })); + + const insertButton = getInsertButton(container); + expect(insertButton.className).toContain("border-2"); + expect(insertButton.className).toContain("ring-1"); + expect(insertButton.className).toContain("shadow"); + }); }); diff --git a/components/canvas/edges/default-edge.tsx b/components/canvas/edges/default-edge.tsx index f399418..a05f888 100644 --- a/components/canvas/edges/default-edge.tsx +++ b/components/canvas/edges/default-edge.tsx @@ -5,10 +5,26 @@ import { BaseEdge, EdgeLabelRenderer, getBezierPath, + useViewport, type EdgeProps, } from "@xyflow/react"; import { Plus } from "lucide-react"; +const MIN_EDGE_INSERT_BUTTON_SCALE = 0.95; +const MAX_EDGE_INSERT_BUTTON_SCALE = 2.2; + +function getEdgeInsertButtonScale(zoom: number): number { + if (!Number.isFinite(zoom) || zoom <= 0) { + return 1; + } + + const inverseZoom = 1 / zoom; + return Math.min( + MAX_EDGE_INSERT_BUTTON_SCALE, + Math.max(MIN_EDGE_INSERT_BUTTON_SCALE, inverseZoom), + ); +} + export type DefaultEdgeInsertAnchor = { edgeId: string; screenX: number; @@ -41,6 +57,7 @@ export default function DefaultEdge({ }: DefaultEdgeProps) { const [isEdgeHovered, setIsEdgeHovered] = useState(false); const [isButtonHovered, setIsButtonHovered] = useState(false); + const { zoom } = useViewport(); const [edgePath, labelX, labelY] = useMemo( () => @@ -58,6 +75,7 @@ export default function DefaultEdge({ const resolvedEdgeId = edgeId ?? id; const canInsert = Boolean(onInsertClick) && !disabled; const isInsertVisible = canInsert && (isMenuOpen || isEdgeHovered || isButtonHovered); + const buttonScale = getEdgeInsertButtonScale(zoom); const handleInsertClick = (event: MouseEvent) => { if (!onInsertClick || disabled) { @@ -97,9 +115,10 @@ export default function DefaultEdge({ aria-label="Insert node" aria-hidden={!isInsertVisible} disabled={!canInsert} - className="nodrag nopan absolute h-7 w-7 items-center justify-center rounded-full border border-border bg-background text-foreground shadow-sm transition-opacity" + className="nodrag nopan absolute h-8 w-8 items-center justify-center rounded-full border-2 border-border/90 bg-background/95 text-foreground shadow-[0_2px_10px_rgba(0,0,0,0.28)] ring-1 ring-background/90 transition-opacity transition-shadow" style={{ - transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`, + transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px) scale(${buttonScale})`, + transformOrigin: "center center", opacity: isInsertVisible ? 1 : 0, pointerEvents: isInsertVisible ? "all" : "none", display: "flex", @@ -108,7 +127,7 @@ export default function DefaultEdge({ onMouseLeave={() => setIsButtonHovered(false)} onClick={handleInsertClick} > -