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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user