feat(handles): allow 1-48 bottom connection points via slider

Replaces the 4-option dropdown with a 1..48 slider. Existing handle IDs
('bottom', 'bottom-2'..'bottom-4') are preserved and the hand-tuned pixel
positions for counts 1..4 are locked in (regression-tested) so saved
canvases render identically. Counts >=5 use uniform spacing; node card
auto-grows in width so handles stay clickable. Existing edge-remap on
shrink scales unchanged thanks to its index-based loop.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-05-03 21:09:14 +02:00
parent ad958feabd
commit 43761c60cb
9 changed files with 206 additions and 77 deletions
@@ -56,8 +56,9 @@ vi.mock('@/utils/propertyIcons', () => ({
}))
vi.mock('@/utils/handleUtils', () => ({
BOTTOM_HANDLE_IDS: ['bottom'],
BOTTOM_HANDLE_POSITIONS: { 1: [50] },
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
bottomHandlePositions: () => [50],
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
}))
beforeEach(() => { mockZoom = 1 })
@@ -9,7 +9,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
icon: LucideIcon
@@ -56,7 +56,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
: 'none',
opacity: data.status === 'offline' ? 0.55 : 1,
minWidth: 140,
// Grow node width when many bottom handles so each stays clickable (~14px slot).
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
width: width ? '100%' : undefined,
height: height ? '100%' : undefined,
}}
@@ -173,9 +174,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</>
)}
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
const sourceId = BOTTOM_HANDLE_IDS[idx]
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
const sourceId = bottomHandleId(idx)
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
<Handle
+19 -15
View File
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSepa
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
@@ -366,21 +367,24 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Bottom connection points (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<Select
value={String(form.bottom_handles ?? 1)}
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
>
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Bottom connection points selector">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
</div>
<input
type="range"
min={MIN_BOTTOM_HANDLES}
max={MAX_BOTTOM_HANDLES}
step={1}
value={clampBottomHandles(form.bottom_handles ?? 1)}
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
aria-label="Bottom connection points slider"
className="w-full accent-[#00d4ff] cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
<span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span>
</div>
</div>
)}
@@ -367,18 +367,37 @@ describe('NodeModal', () => {
it('defaults bottom_handles to 1', () => {
renderModal({ initial: BASE })
expect(selects()[2].value).toBe('1')
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('1')
})
it('pre-fills bottom_handles from initial', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
expect(selects()[2].value).toBe('3')
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('3')
})
it('submits updated bottom_handles', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(selects()[2], { target: { value: '4' } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
fireEvent.change(slider, { target: { value: '12' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
})
it('supports the full 1..48 range', () => {
const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.min).toBe('1')
expect(slider.max).toBe('48')
fireEvent.change(slider, { target: { value: '48' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
})
it('clamps pre-filled out-of-range values into [1,48]', () => {
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('48')
})
})
@@ -721,6 +721,24 @@ describe('canvasStore', () => {
const updated = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
expect(updated?.sourceHandle).toBe('bottom')
})
// Regression: handle cap raised from 4 to 48 — remap must scale.
it('remaps high-count handles when shrinking from 12 to 2', () => {
const node = makeNode('n1', { bottom_handles: 12 })
const edges = [
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'bottom-2' },
{ ...makeEdge('e3', 'n1', 'n2'), sourceHandle: 'bottom-5' },
{ ...makeEdge('e12', 'n1', 'n2'), sourceHandle: 'bottom-12' },
]
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 2 })
const after = useCanvasStore.getState().edges
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom-2')
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
})
})
describe('canvasStore — custom style apply', () => {
+1
View File
@@ -93,6 +93,7 @@ export interface NodeData extends Record<string, unknown> {
height?: number
}
custom_icon?: string
/** Number of bottom connection points, 1..48. Default 1 (centered). */
bottom_handles?: number
}
@@ -1,51 +1,105 @@
import { describe, it, expect } from 'vitest'
import {
BOTTOM_HANDLE_IDS,
BOTTOM_HANDLE_POSITIONS,
MIN_BOTTOM_HANDLES,
MAX_BOTTOM_HANDLES,
bottomHandleId,
bottomHandlePositions,
clampBottomHandles,
normalizeHandle,
removedBottomHandleIds,
} from '../handleUtils'
describe('BOTTOM_HANDLE_IDS', () => {
describe('bottomHandleId', () => {
it('first id is always "bottom" for backward compatibility', () => {
expect(BOTTOM_HANDLE_IDS[0]).toBe('bottom')
expect(bottomHandleId(0)).toBe('bottom')
})
it('has ids for 14 handles', () => {
expect(BOTTOM_HANDLE_IDS).toHaveLength(4)
expect(BOTTOM_HANDLE_IDS).toEqual(['bottom', 'bottom-2', 'bottom-3', 'bottom-4'])
it('subsequent ids follow bottom-N pattern (1-indexed shift)', () => {
expect(bottomHandleId(1)).toBe('bottom-2')
expect(bottomHandleId(2)).toBe('bottom-3')
expect(bottomHandleId(3)).toBe('bottom-4')
expect(bottomHandleId(11)).toBe('bottom-12')
expect(bottomHandleId(47)).toBe('bottom-48')
})
})
describe('BOTTOM_HANDLE_POSITIONS', () => {
describe('clampBottomHandles', () => {
it('clamps below MIN to MIN', () => {
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
})
it('clamps above MAX to MAX', () => {
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
})
it('returns MIN for non-finite or non-number', () => {
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
})
it('floors fractional values', () => {
expect(clampBottomHandles(3.9)).toBe(3)
})
it('passes valid integers through', () => {
expect(clampBottomHandles(1)).toBe(1)
expect(clampBottomHandles(24)).toBe(24)
expect(clampBottomHandles(48)).toBe(48)
})
})
describe('bottomHandlePositions — backward-compat lock for 1..4', () => {
// These exact arrays are the pre-multi-handle hand-tuned positions.
// Existing user canvases depend on them — do NOT change.
it('1 handle is centered at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[1]).toEqual([50])
expect(bottomHandlePositions(1)).toEqual([50])
})
it('2 handles are symmetric', () => {
const [a, b] = BOTTOM_HANDLE_POSITIONS[2]
expect(a).toBeLessThan(50)
expect(b).toBeGreaterThan(50)
expect(a + b).toBe(100)
it('2 handles use exact prior positions', () => {
expect(bottomHandlePositions(2)).toEqual([25, 75])
})
it('3 handles include a center at 50%', () => {
expect(BOTTOM_HANDLE_POSITIONS[3]).toContain(50)
expect(BOTTOM_HANDLE_POSITIONS[3]).toHaveLength(3)
it('3 handles use exact prior positions', () => {
expect(bottomHandlePositions(3)).toEqual([20, 50, 80])
})
it('4 handles are evenly spaced', () => {
const pos = BOTTOM_HANDLE_POSITIONS[4]
expect(pos).toHaveLength(4)
// All values should be between 0 and 100 exclusive
it('4 handles use exact prior positions', () => {
expect(bottomHandlePositions(4)).toEqual([15, 38, 62, 85])
})
})
describe('bottomHandlePositions — uniform spacing for ≥5', () => {
it('returns count entries', () => {
expect(bottomHandlePositions(5)).toHaveLength(5)
expect(bottomHandlePositions(12)).toHaveLength(12)
expect(bottomHandlePositions(48)).toHaveLength(48)
})
it('all positions strictly between 0 and 100', () => {
const pos = bottomHandlePositions(48)
pos.forEach((p) => {
expect(p).toBeGreaterThan(0)
expect(p).toBeLessThan(100)
})
// Positions should be strictly increasing
})
it('positions are strictly increasing and uniform', () => {
const pos = bottomHandlePositions(12)
for (let i = 1; i < pos.length; i++) {
expect(pos[i]).toBeGreaterThan(pos[i - 1])
}
const step = 100 / 13
expect(pos[0]).toBeCloseTo(step, 5)
expect(pos[11]).toBeCloseTo(step * 12, 5)
})
it('clamps out-of-range counts before computing', () => {
expect(bottomHandlePositions(0)).toEqual([50])
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
})
})
@@ -63,16 +117,10 @@ describe('normalizeHandle', () => {
expect(normalizeHandle('bottom-t')).toBe('bottom')
})
it('maps bottom-2-t → bottom-2', () => {
it('maps bottom-N-t → bottom-N for any N', () => {
expect(normalizeHandle('bottom-2-t')).toBe('bottom-2')
})
it('maps bottom-3-t → bottom-3', () => {
expect(normalizeHandle('bottom-3-t')).toBe('bottom-3')
})
it('maps bottom-4-t → bottom-4', () => {
expect(normalizeHandle('bottom-4-t')).toBe('bottom-4')
expect(normalizeHandle('bottom-12-t')).toBe('bottom-12')
expect(normalizeHandle('bottom-48-t')).toBe('bottom-48')
})
it('passes through non-stub handles unchanged', () => {
@@ -90,22 +138,35 @@ describe('removedBottomHandleIds', () => {
})
it('4 → 1 removes bottom-2, bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removed).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
expect(removedBottomHandleIds(4, 1)).toEqual(new Set(['bottom-2', 'bottom-3', 'bottom-4']))
})
it('4 → 2 removes bottom-3, bottom-4', () => {
const removed = removedBottomHandleIds(4, 2)
expect(removed).toEqual(new Set(['bottom-3', 'bottom-4']))
expect(removedBottomHandleIds(4, 2)).toEqual(new Set(['bottom-3', 'bottom-4']))
})
it('3 → 2 removes only bottom-3', () => {
const removed = removedBottomHandleIds(3, 2)
expect(removed).toEqual(new Set(['bottom-3']))
expect(removedBottomHandleIds(3, 2)).toEqual(new Set(['bottom-3']))
})
it('never removes "bottom" (index 0)', () => {
const removed = removedBottomHandleIds(4, 1)
expect(removedBottomHandleIds(4, 1).has('bottom')).toBe(false)
})
// Regression: scaling the cap from 4 → 48 must not break the remap loop.
it('scales to high counts (48 → 1 removes 47 ids)', () => {
const removed = removedBottomHandleIds(48, 1)
expect(removed.size).toBe(47)
expect(removed.has('bottom-2')).toBe(true)
expect(removed.has('bottom-48')).toBe(true)
expect(removed.has('bottom')).toBe(false)
})
it('10 → 2 leaves bottom + bottom-2, removes bottom-3..bottom-10', () => {
const removed = removedBottomHandleIds(10, 2)
expect(removed.size).toBe(8)
expect(removed.has('bottom-2')).toBe(false)
expect(removed.has('bottom-3')).toBe(true)
expect(removed.has('bottom-10')).toBe(true)
})
})
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, Waypoint } from '@/types'
import { normalizeHandle } from '@/utils/handleUtils'
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -104,7 +104,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
properties: n.data.properties ?? [],
width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null,
bottom_handles: n.data.bottom_handles ?? 1,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
pos_x: n.position.x,
pos_y: n.position.y,
}
@@ -155,7 +155,7 @@ export function deserializeApiNode(
id: n.id,
type: normalizedType,
position: { x: n.pos_x, y: n.pos_y },
data: { ...n, type: normalizedType } as unknown as NodeData,
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
+34 -10
View File
@@ -2,20 +2,44 @@
* Bottom handle configuration for multi-handle nodes.
*
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index 1 = 'bottom-2', index 2 = 'bottom-3', index 3 = 'bottom-4'
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
*
* Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', 'bottom-3-t', 'bottom-4-t'
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
*/
export const BOTTOM_HANDLE_IDS = ['bottom', 'bottom-2', 'bottom-3', 'bottom-4'] as const
export const MIN_BOTTOM_HANDLES = 1
export const MAX_BOTTOM_HANDLES = 48
/** Left % position for each handle slot, per count. */
export const BOTTOM_HANDLE_POSITIONS: Record<number, number[]> = {
1: [50],
2: [25, 75],
3: [20, 50, 80],
4: [15, 38, 62, 85],
/** Returns the source handle ID at a given slot index. */
export function bottomHandleId(idx: number): string {
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
}
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */
export function clampBottomHandles(n: unknown): number {
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
const i = Math.floor(n)
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
return i
}
/**
* Left % positions for each handle slot.
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
* positions on canvases saved before the multi-handle expansion.
* Counts ≥ 5 use uniform spacing.
*/
export function bottomHandlePositions(count: number): number[] {
const c = clampBottomHandles(count)
switch (c) {
case 1: return [50]
case 2: return [25, 75]
case 3: return [20, 50, 80]
case 4: return [15, 38, 62, 85]
default: return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
}
}
/**
@@ -39,7 +63,7 @@ export function normalizeHandle(h: string | null | undefined): string | null {
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
const removed = new Set<string>()
for (let i = newCount; i < oldCount; i++) {
removed.add(BOTTOM_HANDLE_IDS[i])
removed.add(bottomHandleId(i))
}
return removed
}