From e39ab7d5300c64c4bd569abde45c11fc898ea566 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 4 Jul 2026 12:59:08 +0200 Subject: [PATCH] fix: restore connection-handle magnet area (unclip node root) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overflow-hidden added to the BaseNode root (to tame the oversized 16px NodeResizer handles) also clipped the connection handles, which sit centred on the node edge with half their box outside it. CSS overflow:hidden disables pointer events in the clipped region, so the outer half of every handle — the side an edge approaches from — became non-interactive, halving the magnet area. - Remove overflow-hidden from the BaseNode root so handles stay fully grabbable. - Shrink the NodeResizer handles 16px -> 8px (rounded): the reason the clip was added in the first place, so the resizer no longer looks oversized when zoomed out without relying on clipping. - Enlarge the invisible target (magnet) hit area 12px -> 20px. - Set connectionRadius={30} on ReactFlow for stronger distance-based snapping. ha-relevant: yes --- .../src/components/canvas/CanvasContainer.tsx | 1 + .../canvas/__tests__/BaseNode.test.tsx | 8 ++++ .../src/components/canvas/nodes/BaseNode.tsx | 4 +- .../components/canvas/nodes/SideHandles.tsx | 2 +- .../nodes/__tests__/SideHandles.test.tsx | 41 +++++++++++++++++++ 5 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 57741c7..3f8b91e 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -192,6 +192,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o colorMode={theme.colors.reactFlowColorMode} elevateNodesOnSelect={false} connectionMode={ConnectionMode.Loose} + connectionRadius={30} isValidConnection={isValidConnection} > { expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px') }) + // Regression: the node root must NOT clip its own bounds, otherwise the outer + // half of each connection handle (which sits centred on the edge) becomes + // non-interactive and the "magnet" snap area is halved. + it('root does not clip overflow (keeps handles grabbable)', () => { + const { container } = renderBaseNode({}) + expect((container.firstChild as HTMLElement).className).not.toContain('overflow-hidden') + }) + it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => { mockZoom = 0.5 const node = makeNode({ status: 'online' }) diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index bce6d6f..fbfda1b 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -56,7 +56,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: return (
) diff --git a/frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx b/frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx new file mode 100644 index 0000000..a0f5114 --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/SideHandles.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { SideHandles } from '../SideHandles' +import type { NodeData } from '@/types' + +function renderHandles(data: Partial = {}) { + const full: NodeData = { label: 'n', type: 'server', status: 'online', services: [], ...data } + return render( + + + + ) +} + +describe('SideHandles', () => { + it('renders a source + invisible target handle per slot', () => { + // default node: top=1, bottom=1, left=0, right=0 + const { container } = renderHandles({}) + expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(2) + expect(container.querySelectorAll('.react-flow__handle.target').length).toBe(2) + }) + + it('target (magnet) handle hit area is large enough to snap onto (20px)', () => { + const { container } = renderHandles({}) + const target = container.querySelector('.react-flow__handle.target') as HTMLElement + expect(target.style.width).toBe('20px') + expect(target.style.height).toBe('20px') + expect(target.style.opacity).toBe('0') + }) + + it('renders configured per-side counts', () => { + const { container } = renderHandles({ top_handles: 2, left_handles: 3, right_handles: 1, bottom_handles: 1 }) + expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(7) + }) +})