feat: customizable connection points per side (issue #243)

Generalize the bottom-only handle machinery to all four sides. Top/Bottom
keep their single-handle default; Left/Right are opt-in (default 0) so
existing diagrams render unchanged. Handle IDs stay keyed off the bare side
name at slot 0, keeping saved edges valid.

- handleUtils: side-generic API (handleId, handlePositions, clampHandles,
  sideDefault, removedHandleIds, sideHandleCount); legacy bottom fns kept as
  delegating aliases
- BaseNode + ProxmoxGroupNode render all sides via new shared SideHandles
- updateNode remaps edges on any side's shrink (fallback to slot-0 / bottom)
- serializer round-trips top/left/right_handles
- NodeModal: spatial cross of -/N/+ steppers around a node preview, replacing
  the four stacked sliders; seeds per-type defaults
- CustomStyleModal: per-type default connection points per side

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-03 21:25:14 +02:00
parent c9a35b730d
commit b708a08fd0
15 changed files with 701 additions and 183 deletions
@@ -9,7 +9,7 @@ let mockZoom = 1
vi.mock('@xyflow/react', () => ({ vi.mock('@xyflow/react', () => ({
Handle: () => null, Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' }, Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
NodeResizer: () => null, NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(), useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ zoom: mockZoom }), useViewport: () => ({ zoom: mockZoom }),
@@ -59,14 +59,9 @@ vi.mock('@/utils/propertyIcons', () => ({
resolvePropertyIcon: (icon: string | null) => icon ? Server : null, resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
})) }))
vi.mock('@/utils/handleUtils', () => ({ // handleUtils is pure — use the real implementation so the side-generic API
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, // (SIDES, handleId, handlePositions, sideHandleCount, …) stays in sync.
bottomHandlePositions: (count: number) => { vi.mock('@/utils/handleUtils', async (importOriginal) => await importOriginal())
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
},
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
}))
beforeEach(() => { mockZoom = 1 }) beforeEach(() => { mockZoom = 1 })
@@ -176,15 +171,23 @@ describe('BaseNode — properties rendering', () => {
}) })
}) })
describe('BaseNode — port numbers (issue #20)', () => { describe('BaseNode — port numbers (issue #20 / #243)', () => {
it('renders a number above each bottom handle when show_port_numbers is on', () => { it('renders a number on each connection point when show_port_numbers is on', () => {
// bottom=4 → labels 1..4; top defaults to 1 → an extra "1" on top.
renderBaseNode({ bottom_handles: 4, show_port_numbers: true }) renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined() expect(screen.getAllByText('1')).toHaveLength(2) // top slot 0 + bottom slot 0
expect(screen.getByText('2')).toBeDefined() expect(screen.getByText('2')).toBeDefined()
expect(screen.getByText('3')).toBeDefined() expect(screen.getByText('3')).toBeDefined()
expect(screen.getByText('4')).toBeDefined() expect(screen.getByText('4')).toBeDefined()
}) })
it('labels left/right connection points too when enabled', () => {
// top=1, bottom=1, left=2, right=0 → labels: two "1" (top+bottom) + "2" (left).
renderBaseNode({ bottom_handles: 1, left_handles: 2, show_port_numbers: true })
expect(screen.getByText('2')).toBeDefined()
expect(screen.getAllByText('1')).toHaveLength(3) // top + bottom + left slot 0
})
it('does not render port numbers when show_port_numbers is off', () => { it('does not render port numbers when show_port_numbers is off', () => {
renderBaseNode({ bottom_handles: 4 }) renderBaseNode({ bottom_handles: 4 })
expect(screen.queryByText('1')).toBeNull() expect(screen.queryByText('1')).toBeNull()
@@ -192,8 +195,9 @@ describe('BaseNode — port numbers (issue #20)', () => {
}) })
it('numbers match the handle count', () => { it('numbers match the handle count', () => {
// bottom=2 → 1,2; top default 1 adds one more "1"; no "3".
renderBaseNode({ bottom_handles: 2, show_port_numbers: true }) renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined() expect(screen.getAllByText('1')).toHaveLength(2)
expect(screen.getByText('2')).toBeDefined() expect(screen.getByText('2')).toBeDefined()
expect(screen.queryByText('3')).toBeNull() expect(screen.queryByText('3')).toBeNull()
}) })
@@ -1,5 +1,5 @@
import { createElement, useEffect, useMemo } from 'react' import { createElement, useEffect, useMemo } from 'react'
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' import { NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react' import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
@@ -10,7 +10,8 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore' import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' import { sideHandleCount } from '@/utils/handleUtils'
import { SideHandles } from './SideHandles'
import { getServiceUrl } from '@/utils/serviceUrl' import { getServiceUrl } from '@/utils/serviceUrl'
interface BaseNodeProps extends NodeProps<Node<NodeData>> { interface BaseNodeProps extends NodeProps<Node<NodeData>> {
@@ -24,7 +25,7 @@ function formatStorage(gb: number): string {
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) { export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
const updateNodeInternals = useUpdateNodeInternals() const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals])
const { zoom } = useViewport() const { zoom } = useViewport()
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom]) const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
@@ -47,6 +48,12 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const showLegacyHardware = !data.properties && data.show_hardware && const showLegacyHardware = !data.properties && data.show_hardware &&
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null) (data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
// Resolved per-side connection-point counts (missing field → side default).
const topCount = sideHandleCount(data, 'top')
const bottomCount = sideHandleCount(data, 'bottom')
const leftCount = sideHandleCount(data, 'left')
const rightCount = sideHandleCount(data, 'right')
return ( return (
<div <div
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden" className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
@@ -62,8 +69,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44` ? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
: 'none', : 'none',
opacity: data.status === 'offline' ? 0.55 : 1, opacity: data.status === 'offline' ? 0.55 : 1,
// Grow node width when many bottom handles so each stays clickable (~14px slot). // Grow node so each handle stays clickable (~14px slot on each axis).
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14), minWidth: Math.max(140, Math.max(topCount, bottomCount) * 14),
minHeight: Math.max(50, Math.max(leftCount, rightCount) * 14),
width: width ? '100%' : undefined, width: width ? '100%' : undefined,
height: height ? '100%' : undefined, height: height ? '100%' : undefined,
}} }}
@@ -75,13 +83,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
lineStyle={{ borderColor: 'transparent' }} lineStyle={{ borderColor: 'transparent' }}
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }} handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
/> />
<Handle <SideHandles
type="source" data={data}
position={Position.Top} sides={['top', 'left', 'right']}
id="top" handleBackground={theme.colors.handleBackground}
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} handleBorder={theme.colors.handleBorder}
labelColor={theme.colors.nodeSubtextColor}
showLabels
/> />
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{/* Status dot — absolute to avoid affecting node auto-width */} {/* Status dot — absolute to avoid affecting node auto-width */}
<div <div
@@ -251,40 +260,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</> </>
)} )}
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { <SideHandles
const sourceId = bottomHandleId(idx) data={data}
const targetId = `${sourceId}-t` sides={['bottom']}
return ( handleBackground={theme.colors.handleBackground}
<span key={sourceId}> handleBorder={theme.colors.handleBorder}
{data.show_port_numbers && ( labelColor={theme.colors.nodeSubtextColor}
<span showLabels
className="absolute font-mono leading-none pointer-events-none select-none" />
style={{
left: `${leftPct}%`,
bottom: 3,
transform: 'translateX(-50%)',
fontSize: 7,
color: theme.colors.nodeSubtextColor,
}}
>
{idx + 1}
</span>
)}
<Handle
type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
</div> </div>
) )
} }
@@ -10,13 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp' import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import { SideHandles } from './SideHandles'
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) { export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { id, data, selected } = props const { id, data, selected } = props
const updateNodeInternals = useUpdateNodeInternals() const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals])
const activeTheme = useThemeStore((s) => s.activeTheme) const activeTheme = useThemeStore((s) => s.activeTheme)
const hideIp = useCanvasStore((s) => s.hideIp) const hideIp = useCanvasStore((s) => s.hideIp)
@@ -145,33 +145,12 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
<div className="flex-1 relative" /> <div className="flex-1 relative" />
</div> </div>
<Handle <SideHandles
type="source" data={data}
position={Position.Top} handleBackground={theme.colors.handleBackground}
id="top" handleBorder={theme.colors.handleBorder}
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} labelColor={theme.colors.nodeSubtextColor}
/> />
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
const sourceId = bottomHandleId(idx)
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
<Handle
type="source"
position={Position.Bottom}
id={sourceId}
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<Handle
type="target"
position={Position.Bottom}
id={targetId}
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})}
{/* Cluster handles */} {/* Cluster handles */}
<Handle <Handle
@@ -0,0 +1,84 @@
import type { CSSProperties } from 'react'
import { Handle, Position } from '@xyflow/react'
import type { NodeData } from '@/types'
import {
SIDES,
handleId,
handlePositions,
isVerticalSide,
sideHandleCount,
type Side,
} from '@/utils/handleUtils'
const POSITION: Record<Side, Position> = {
top: Position.Top,
bottom: Position.Bottom,
left: Position.Left,
right: Position.Right,
}
interface SideHandlesProps {
data: NodeData
handleBackground: string
handleBorder: string
/** Colour for the optional port-number labels. */
labelColor: string
/** Which sides to render. Defaults to all four. */
sides?: readonly Side[]
/** When true, render port-number labels if data.show_port_numbers is set. */
showLabels?: boolean
}
/**
* Renders the per-side React Flow handles (visible source + invisible target)
* for a node, spaced along each side's axis. Shared by BaseNode and the
* container-mode ProxmoxGroupNode so handle IDs stay identical across both.
*/
export function SideHandles({
data,
handleBackground,
handleBorder,
labelColor,
sides = SIDES,
showLabels = false,
}: SideHandlesProps) {
return (
<>
{sides.map((side) => {
const vertical = isVerticalSide(side)
return handlePositions(side, sideHandleCount(data, side)).map((pct, idx) => {
const sourceId = handleId(side, idx)
const targetId = `${sourceId}-t`
const offset: CSSProperties = vertical ? { top: `${pct}%` } : { left: `${pct}%` }
const labelStyle: CSSProperties = vertical
? { top: `${pct}%`, [side]: 3, transform: 'translateY(-50%)' }
: { left: `${pct}%`, [side]: 3, transform: 'translateX(-50%)' }
return (
<span key={sourceId}>
{showLabels && data.show_port_numbers && (
<span
className="absolute font-mono leading-none pointer-events-none select-none"
style={{ ...labelStyle, fontSize: 7, color: labelColor }}
>
{idx + 1}
</span>
)}
<Handle
type="source"
position={POSITION[side]}
id={sourceId}
style={{ ...offset, background: handleBackground, borderColor: handleBorder }}
/>
<Handle
type="target"
position={POSITION[side]}
id={targetId}
style={{ ...offset, opacity: 0, width: 12, height: 12 }}
/>
</span>
)
})
})}
</>
)
}
@@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { clampHandles, sideDefault } from '@/utils/handleUtils'
import { THEMES } from '@/utils/themes' import { THEMES } from '@/utils/themes'
import { applyOpacity } from '@/utils/colorUtils' import { applyOpacity } from '@/utils/colorUtils'
import type { import type {
@@ -178,6 +179,33 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
</div> </div>
</div> </div>
<div className="border-t border-[#30363d] pt-3">
<div className="text-xs text-[#8b949e] mb-1">Default connection points</div>
<div className="text-xs text-[#8b949e]/60 mb-2">New {NODE_TYPE_LABELS[nodeType]} nodes start with these (064 per side)</div>
<div className="grid grid-cols-2 gap-2">
{([
['Top', 'top', 'topHandles'],
['Right', 'right', 'rightHandles'],
['Bottom', 'bottom', 'bottomHandles'],
['Left', 'left', 'leftHandles'],
] as const).map(([label, side, key]) => (
<div key={side} className="flex items-center gap-2">
<span className="text-xs text-[#8b949e] w-12">{label}</span>
<input
type="number"
min={sideDefault(side)}
max={64}
step={1}
value={style[key] ?? sideDefault(side)}
onChange={(e) => set(key, clampHandles(side, parseInt(e.target.value, 10)))}
aria-label={`${label} default connection points`}
className="w-16 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
/>
</div>
))}
</div>
</div>
<Button <Button
size="sm" size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90" className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
+112 -22
View File
@@ -6,11 +6,12 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors' import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons' import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { BrandIconPicker } from './BrandIconPicker' import { BrandIconPicker } from './BrandIconPicker'
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils' import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent' import { getValidParentTypes } from '@/utils/virtualEdgeParent'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
@@ -24,6 +25,65 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Generic', types: ['generic', 'groupRect'] }, { label: 'Generic', types: ['generic', 'groupRect'] },
] ]
// Maps a side to its per-type default field on NodeTypeStyle.
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
top: 'topHandles',
bottom: 'bottomHandles',
left: 'leftHandles',
right: 'rightHandles',
}
/**
* Compact per-side connection-point control: [ N +] with a typable value.
* Placed spatially around a node preview (see the Connection Points section).
*/
function CPStepper({ label, side, value, onChange }: {
label: string
side: Side
value: number
onChange: (v: number) => void
}) {
const min = sideDefault(side)
const labelEl = <span className="text-[10px] text-muted-foreground/80 leading-none">{label}</span>
const belowLabel = side === 'bottom'
const btn = 'w-6 h-full flex items-center justify-center text-sm text-muted-foreground hover:text-foreground hover:bg-[#21262d] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-default'
return (
<div className="flex flex-col items-center gap-1">
{!belowLabel && labelEl}
<div className="flex items-center h-7 rounded-md border border-[#30363d] bg-[#0d1117] overflow-hidden">
<button
type="button"
aria-label={`Decrease ${label} connection points`}
onClick={() => onChange(clampHandles(side, value - 1))}
disabled={value <= min}
className={btn}
>
</button>
<input
type="number"
min={min}
max={MAX_HANDLES}
value={value}
aria-label={`${label} connection points`}
onChange={(e) => onChange(clampHandles(side, Number(e.target.value)))}
className="w-9 h-full bg-transparent text-center text-xs font-mono text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
<button
type="button"
aria-label={`Increase ${label} connection points`}
onClick={() => onChange(clampHandles(side, value + 1))}
disabled={value >= MAX_HANDLES}
className={btn}
>
+
</button>
</div>
{belowLabel && labelEl}
</div>
)
}
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health'] const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host'] const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
@@ -94,6 +154,16 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const set = (key: keyof NodeData, value: unknown) => const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value })) setForm((f) => ({ ...f, [key]: value }))
const customStyle = useThemeStore((s) => s.customStyle)
// Effective default count for a side: the per-type style default if set,
// otherwise the intrinsic side default (top/bottom → 1, left/right → 0).
const effectiveSideDefault = (side: Side): number => {
const styleVal = customStyle.nodes[(form.type ?? 'generic') as NodeType]?.[SIDE_STYLE_KEY[side]]
return clampHandles(side, typeof styleVal === 'number' ? styleVal : sideDefault(side))
}
const sideValue = (side: Side): number =>
clampHandles(side, form[handleCountField(side)] ?? effectiveSideDefault(side))
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!form.label?.trim()) { if (!form.label?.trim()) {
@@ -113,8 +183,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
const parent = parentCandidates.find((n) => n.id === safeParentId) const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !isValidParent(parent)) safeParentId = undefined if (!parent || !isValidParent(parent)) safeParentId = undefined
} }
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
onSubmit({ onSubmit({
...form, ...form,
// Persist the resolved per-side counts so type-style defaults (and
// untouched sliders) are baked into the node. Skipped for group types.
...(isGroupType ? {} : {
top_handles: sideValue('top'),
bottom_handles: sideValue('bottom'),
left_handles: sideValue('left'),
right_handles: sideValue('right'),
}),
parent_id: safeParentId, parent_id: safeParentId,
container_mode: canUseContainerMode ? !!form.container_mode : false, container_mode: canUseContainerMode ? !!form.container_mode : false,
}) })
@@ -509,31 +588,42 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div> </div>
</div> </div>
{/* Bottom connection points (not for group containers) */} {/* Connection points per side (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && ( {form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2"> <div className="flex flex-col gap-2.5 col-span-2">
<div className="flex items-center justify-between"> <Label className="text-xs text-muted-foreground">Connection Points</Label>
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label> {/* Spatial cross: each side's stepper sits where that side is. */}
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span> <div className="grid grid-cols-[1fr_auto_1fr] items-center justify-items-center gap-x-2 gap-y-2 py-1">
</div> <div />
<input <CPStepper label="Top" side="top" value={sideValue('top')}
type="range" onChange={(v) => set('top_handles', v)} />
min={MIN_BOTTOM_HANDLES} <div />
max={MAX_BOTTOM_HANDLES}
step={1} <CPStepper label="Left" side="left" value={sideValue('left')}
value={clampBottomHandles(form.bottom_handles ?? 1)} onChange={(v) => set('left_handles', v)} />
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))} <div
aria-label="Bottom connection points slider" className="flex items-center justify-center rounded-md border text-[9px] uppercase tracking-wide font-medium select-none"
className="w-full accent-[#00d4ff] cursor-pointer" style={{
/> width: 64, height: 40,
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono"> borderColor: resolvedNodeColors.border,
<span>{MIN_BOTTOM_HANDLES}</span> background: `${resolvedNodeColors.background}`,
<span>{MAX_BOTTOM_HANDLES}</span> color: resolvedNodeColors.icon,
}}
>
node
</div>
<CPStepper label="Right" side="right" value={sideValue('right')}
onChange={(v) => set('right_handles', v)} />
<div />
<CPStepper label="Bottom" side="bottom" value={sideValue('bottom')}
onChange={(v) => set('bottom_handles', v)} />
<div />
</div> </div>
<div className="flex items-center justify-between pt-1"> <div className="flex items-center justify-between pt-1">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label> <Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span> <span className="text-[10px] text-muted-foreground/60">Label each connection point</span>
</div> </div>
<button <button
type="button" type="button"
@@ -135,6 +135,34 @@ describe('CustomStyleModal', () => {
expect((widthInputs[0] as HTMLInputElement).value).toBe('250') expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
}) })
it('shows per-side default connection-point inputs in the node editor', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
expect(screen.getByText('Default connection points')).toBeDefined()
expect(screen.getByLabelText('Top default connection points')).toBeDefined()
expect(screen.getByLabelText('Left default connection points')).toBeDefined()
})
it('defaults per-side inputs to 1 (top/bottom) and 0 (left/right)', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
expect((screen.getByLabelText('Top default connection points') as HTMLInputElement).value).toBe('1')
expect((screen.getByLabelText('Left default connection points') as HTMLInputElement).value).toBe('0')
})
it('editing a per-side default persists via setCustomStyle on Save', () => {
const onClose = vi.fn()
useCanvasStore.setState({ markUnsaved: vi.fn() })
const setCustomStyle = vi.spyOn(useThemeStore.getState(), 'setCustomStyle')
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
fireEvent.change(screen.getByLabelText('Left default connection points'), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
expect(setCustomStyle).toHaveBeenCalled()
const saved = setCustomStyle.mock.calls.at(-1)?.[0]
expect(saved?.nodes.router?.leftHandles).toBe(3)
})
it('resets abandoned edits when reopened after cancel (mounted parent)', () => { it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
// Parent keeps the modal mounted and only toggles `open`, so the reset must // Parent keeps the modal mounted and only toggles `open`, so the reset must
// happen on the open-prop edge, not via Radix onOpenChange. // happen on the open-prop edge, not via Radix onOpenChange.
@@ -435,57 +435,88 @@ describe('NodeModal', () => {
expect(screen.getByText(/Using default colors for/)).toBeDefined() expect(screen.getByText(/Using default colors for/)).toBeDefined()
}) })
// ── Bottom connection points ─────────────────────────────────────────── // ── Connection points per side (issue #243) ────────────────────────────
it('shows Bottom Connection Points for server type', () => { it('shows the Connection Points section for server type', () => {
renderModal({ initial: BASE }) renderModal({ initial: BASE })
expect(screen.getByText('Bottom Connection Points')).toBeDefined() expect(screen.getByText('Connection Points')).toBeDefined()
expect(screen.getByLabelText('Top connection points')).toBeDefined()
expect(screen.getByLabelText('Right connection points')).toBeDefined()
expect(screen.getByLabelText('Bottom connection points')).toBeDefined()
expect(screen.getByLabelText('Left connection points')).toBeDefined()
}) })
it('hides Bottom Connection Points for groupRect', () => { it('hides Connection Points for groupRect', () => {
renderModal({ initial: { ...BASE, type: 'groupRect' } }) renderModal({ initial: { ...BASE, type: 'groupRect' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull() expect(screen.queryByText('Connection Points')).toBeNull()
}) })
it('hides Bottom Connection Points for group', () => { it('hides Connection Points for group', () => {
renderModal({ initial: { ...BASE, type: 'group' } }) renderModal({ initial: { ...BASE, type: 'group' } })
expect(screen.queryByText('Bottom Connection Points')).toBeNull() expect(screen.queryByText('Connection Points')).toBeNull()
}) })
it('defaults bottom_handles to 1', () => { it('defaults top/bottom to 1 and left/right to 0', () => {
renderModal({ initial: BASE }) renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('1')
expect(slider.value).toBe('1') expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('1')
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).value).toBe('0')
expect((screen.getByLabelText('Right connection points') as HTMLInputElement).value).toBe('0')
}) })
it('pre-fills bottom_handles from initial', () => { it('pre-fills each side from initial', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } }) renderModal({ initial: { ...BASE, top_handles: 2, bottom_handles: 3, left_handles: 4, right_handles: 1 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('2')
expect(slider.value).toBe('3') expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('3')
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).value).toBe('4')
expect((screen.getByLabelText('Right connection points') as HTMLInputElement).value).toBe('1')
}) })
it('submits updated bottom_handles', () => { it('submits per-side counts edited via the number inputs', () => {
const { onSubmit } = renderModal({ initial: BASE }) const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '12' } })
fireEvent.change(slider, { target: { value: '12' } }) fireEvent.change(screen.getByLabelText('Left connection points'), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' })) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12) const payload = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(payload.bottom_handles).toBe(12)
expect(payload.left_handles).toBe(3)
expect(payload.top_handles).toBe(1)
expect(payload.right_handles).toBe(0)
}) })
it('supports the full 1..64 range (issue #20)', () => { it('increments a side with the + stepper button', () => {
const { onSubmit } = renderModal({ initial: BASE }) const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
expect(slider.min).toBe('1') fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
expect(slider.max).toBe('64') fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(slider, { target: { value: '52' } }) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).right_handles).toBe(2)
})
it('disables the button at the side minimum (0 for left/right, 1 for top/bottom)', () => {
renderModal({ initial: BASE })
expect((screen.getByRole('button', { name: 'Decrease Left connection points' }) as HTMLButtonElement).disabled).toBe(true)
expect((screen.getByRole('button', { name: 'Decrease Top connection points' }) as HTMLButtonElement).disabled).toBe(true)
})
it('left/right min is 0, top/bottom min is 1; max is 64 everywhere', () => {
renderModal({ initial: BASE })
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('1')
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).min).toBe('0')
for (const label of ['Top', 'Right', 'Bottom', 'Left']) {
expect((screen.getByLabelText(`${label} connection points`) as HTMLInputElement).max).toBe('64')
}
})
it('supports the full range up to 64 (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '52' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' })) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
}) })
it('clamps pre-filled out-of-range values into [1,64]', () => { it('clamps pre-filled out-of-range values into range', () => {
renderModal({ initial: { ...BASE, bottom_handles: 9999 } }) renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('64')
expect(slider.value).toBe('64')
}) })
it('toggles show_port_numbers and submits it (issue #20)', () => { it('toggles show_port_numbers and submits it (issue #20)', () => {
@@ -1184,6 +1184,60 @@ describe('canvasStore', () => {
expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom') expect(after.find((e) => e.id === 'e3')?.sourceHandle).toBe('bottom')
expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom') expect(after.find((e) => e.id === 'e12')?.sourceHandle).toBe('bottom')
}) })
// ── per-side edge remapping (issue #243) ──────────────────────────────────
it('remaps top edges to "top" slot 0 when top_handles is reduced', () => {
const node = makeNode('n1', { top_handles: 3 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'top-3' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { top_handles: 1 })
expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('top')
})
it('remaps left/right edges to "bottom" when the side drops to 0 (slot 0 gone)', () => {
const node = makeNode('n1', { left_handles: 2, right_handles: 2 })
const edges = [
{ ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'left' },
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'left-2' },
{ ...makeEdge('e3', 'n1', 'n2'), targetHandle: 'right', source: 'n2', target: 'n1' },
]
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
useCanvasStore.getState().updateNode('n1', { left_handles: 0, right_handles: 0 })
const after = useCanvasStore.getState().edges
expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom')
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('bottom')
expect(after.find((e) => e.id === 'e3')?.targetHandle).toBe('bottom')
})
it('remaps a side to its own slot 0 when shrinking but not to 0', () => {
const node = makeNode('n1', { right_handles: 3 })
const edge = { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'right-3' }
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges: [edge] })
useCanvasStore.getState().updateNode('n1', { right_handles: 1 })
expect(useCanvasStore.getState().edges.find((e) => e.id === 'e1')?.sourceHandle).toBe('right')
})
it('leaves edges on other sides untouched when one side shrinks', () => {
const node = makeNode('n1', { top_handles: 3, bottom_handles: 3 })
const edges = [
{ ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom-3' },
{ ...makeEdge('e2', 'n1', 'n2'), sourceHandle: 'top-2' },
]
useCanvasStore.setState({ nodes: [node, makeNode('n2')], edges })
useCanvasStore.getState().updateNode('n1', { bottom_handles: 1 })
const after = useCanvasStore.getState().edges
expect(after.find((e) => e.id === 'e1')?.sourceHandle).toBe('bottom')
expect(after.find((e) => e.id === 'e2')?.sourceHandle).toBe('top-2')
})
}) })
describe('canvasStore — custom style apply', () => { describe('canvasStore — custom style apply', () => {
+20 -16
View File
@@ -11,7 +11,7 @@ import {
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handleId, SIDES } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils' import { applyOpacity } from '@/utils/colorUtils'
import { readHideIp, writeHideIp } from '@/utils/ipDisplay' import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
@@ -357,22 +357,26 @@ export const useCanvasStore = create<CanvasState>((set) => ({
const children = nodes.filter((n) => !!n.parentId) const children = nodes.filter((n) => !!n.parentId)
nodes = [...parents, ...children] nodes = [...parents, ...children]
} }
// Remap edges when bottom_handles is reduced so no edge disappears // Remap edges when any side's handle count is reduced so no edge disappears.
// Removed handles fall back to the side's slot-0 id, or 'bottom' if the
// side dropped to 0 (its slot-0 id no longer exists).
let edges = state.edges let edges = state.edges
if ('bottom_handles' in data && data.bottom_handles != null) { const currentNode = state.nodes.find((n) => n.id === id)
const currentNode = state.nodes.find((n) => n.id === id) for (const side of SIDES) {
const oldCount = currentNode?.data.bottom_handles ?? 1 const field = handleCountField(side)
const newCount = data.bottom_handles if (!(field in data) || data[field] == null) continue
if (newCount < oldCount) { const oldCount = currentNode?.data[field] ?? sideDefault(side)
const removed = removedBottomHandleIds(oldCount, newCount) const newCount = data[field] as number
edges = state.edges.map((e) => { if (newCount >= oldCount) continue
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle)) const removed = removedHandleIds(side, oldCount, newCount)
return { ...e, sourceHandle: 'bottom' } const fallback = newCount === 0 ? 'bottom' : handleId(side, 0)
if (e.target === id && e.targetHandle && removed.has(e.targetHandle)) edges = edges.map((e) => {
return { ...e, targetHandle: 'bottom' } if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
return e return { ...e, sourceHandle: fallback }
}) if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
} return { ...e, targetHandle: fallback }
return e
})
} }
return { nodes, edges, hasUnsavedChanges: true } return { nodes, edges, hasUnsavedChanges: true }
+13 -2
View File
@@ -140,9 +140,15 @@ export interface NodeData extends Record<string, unknown> {
*/ */
collapsed?: boolean collapsed?: boolean
custom_icon?: string custom_icon?: string
/** Number of bottom connection points, 1..64. Default 1 (centered). */ /** Number of top connection points, 0..64. Default 1. */
top_handles?: number
/** Number of bottom connection points, 0..64. Default 1 (centered). */
bottom_handles?: number bottom_handles?: number
/** Show a port number (1..N) above each bottom connection point. */ /** Number of left connection points, 0..64. Default 0 (opt-in). */
left_handles?: number
/** Number of right connection points, 0..64. Default 0 (opt-in). */
right_handles?: number
/** Show a port number (1..N) next to each connection point. */
show_port_numbers?: boolean show_port_numbers?: boolean
/** Text node content (type === 'text') */ /** Text node content (type === 'text') */
text_content?: string text_content?: string
@@ -239,6 +245,11 @@ export interface NodeTypeStyle {
iconOpacity: number iconOpacity: number
width: number width: number
height: number height: number
/** Default connection-point counts per side for new nodes of this type. */
topHandles?: number
bottomHandles?: number
leftHandles?: number
rightHandles?: number
} }
export interface EdgeTypeStyle { export interface EdgeTypeStyle {
@@ -305,6 +305,26 @@ describe('deserializeApiNode — regular node', () => {
expect(result.height).toBeUndefined() expect(result.height).toBeUndefined()
}) })
// Backward-compat: pre-#243 saves have no top/left/right_handles fields.
it('defaults per-side handle counts for legacy nodes (top/bottom=1, left/right=0)', () => {
const result = deserializeApiNode(makeApiNode(), emptyMap)
expect(result.data.top_handles).toBe(1)
expect(result.data.bottom_handles).toBe(1)
expect(result.data.left_handles).toBe(0)
expect(result.data.right_handles).toBe(0)
})
it('round-trips explicit per-side handle counts', () => {
const result = deserializeApiNode(
makeApiNode({ top_handles: 2, bottom_handles: 5, left_handles: 3, right_handles: 1 }),
emptyMap,
)
expect(result.data.top_handles).toBe(2)
expect(result.data.bottom_handles).toBe(5)
expect(result.data.left_handles).toBe(3)
expect(result.data.right_handles).toBe(1)
})
it('sets parentId and extent for children of container proxmox', () => { it('sets parentId and extent for children of container proxmox', () => {
const map = new Map([['px1', true]]) const map = new Map([['px1', true]])
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map) const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
@@ -2,11 +2,20 @@ import { describe, it, expect } from 'vitest'
import { import {
MIN_BOTTOM_HANDLES, MIN_BOTTOM_HANDLES,
MAX_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES,
MAX_HANDLES,
bottomHandleId, bottomHandleId,
bottomHandlePositions, bottomHandlePositions,
clampBottomHandles, clampBottomHandles,
normalizeHandle, normalizeHandle,
removedBottomHandleIds, removedBottomHandleIds,
handleId,
handlePositions,
clampHandles,
removedHandleIds,
sideDefault,
handleCountField,
isVerticalSide,
SIDES,
} from '../handleUtils' } from '../handleUtils'
describe('bottomHandleId', () => { describe('bottomHandleId', () => {
@@ -176,3 +185,125 @@ describe('removedBottomHandleIds', () => {
expect(removed.has('bottom-10')).toBe(true) expect(removed.has('bottom-10')).toBe(true)
}) })
}) })
// ─── Side-generic API (issue #243) ──────────────────────────────────────────
describe('sideDefault', () => {
it('top/bottom default to 1 (backward-compatible)', () => {
expect(sideDefault('top')).toBe(1)
expect(sideDefault('bottom')).toBe(1)
})
it('left/right default to 0 (opt-in, no visual change to old diagrams)', () => {
expect(sideDefault('left')).toBe(0)
expect(sideDefault('right')).toBe(0)
})
})
describe('handleCountField', () => {
it('maps each side to its NodeData field', () => {
expect(handleCountField('top')).toBe('top_handles')
expect(handleCountField('bottom')).toBe('bottom_handles')
expect(handleCountField('left')).toBe('left_handles')
expect(handleCountField('right')).toBe('right_handles')
})
})
describe('handleId', () => {
it('slot 0 is the bare side name for every side', () => {
expect(handleId('top', 0)).toBe('top')
expect(handleId('bottom', 0)).toBe('bottom')
expect(handleId('left', 0)).toBe('left')
expect(handleId('right', 0)).toBe('right')
})
it('slot N ≥ 1 follows side-N pattern (1-indexed shift)', () => {
expect(handleId('top', 1)).toBe('top-2')
expect(handleId('left', 2)).toBe('left-3')
expect(handleId('right', 47)).toBe('right-48')
})
it('bottom matches the legacy alias', () => {
for (let i = 0; i < 5; i++) expect(handleId('bottom', i)).toBe(bottomHandleId(i))
})
})
describe('clampHandles', () => {
it('min is the side default (0 for L/R, 1 for T/B)', () => {
expect(clampHandles('top', 0)).toBe(1)
expect(clampHandles('bottom', -3)).toBe(1)
expect(clampHandles('left', -3)).toBe(0)
expect(clampHandles('right', 0)).toBe(0)
})
it('max is 64 for every side', () => {
expect(clampHandles('top', 9999)).toBe(MAX_HANDLES)
expect(clampHandles('left', 65)).toBe(64)
})
it('non-finite / non-number falls back to side min', () => {
expect(clampHandles('left', NaN)).toBe(0)
expect(clampHandles('top', undefined)).toBe(1)
expect(clampHandles('right', '4' as unknown)).toBe(0)
})
it('floors fractional values', () => {
expect(clampHandles('left', 3.9)).toBe(3)
})
})
describe('handlePositions', () => {
it('horizontal sides reuse the bottom distribution', () => {
expect(handlePositions('top', 3)).toEqual([20, 50, 80])
expect(handlePositions('bottom', 4)).toEqual([15, 38, 62, 85])
})
it('vertical sides use the same offsets (applied to the top axis)', () => {
expect(handlePositions('left', 2)).toEqual([25, 75])
expect(handlePositions('right', 1)).toEqual([50])
})
it('count 0 yields no handles (only reachable for L/R)', () => {
expect(handlePositions('left', 0)).toEqual([])
expect(handlePositions('right', 0)).toEqual([])
})
it('top/bottom clamp 0 up to 1 (never empty)', () => {
expect(handlePositions('top', 0)).toEqual([50])
expect(handlePositions('bottom', 0)).toEqual([50])
})
})
describe('isVerticalSide', () => {
it('left/right are vertical; top/bottom are not', () => {
expect(isVerticalSide('left')).toBe(true)
expect(isVerticalSide('right')).toBe(true)
expect(isVerticalSide('top')).toBe(false)
expect(isVerticalSide('bottom')).toBe(false)
})
})
describe('normalizeHandle — all sides', () => {
it('maps left-t / right-t and their -N variants to source ids', () => {
expect(normalizeHandle('left-t')).toBe('left')
expect(normalizeHandle('right-t')).toBe('right')
expect(normalizeHandle('left-2-t')).toBe('left-2')
expect(normalizeHandle('right-48-t')).toBe('right-48')
expect(normalizeHandle('top-3-t')).toBe('top-3')
})
it('passes through source ids for every side', () => {
expect(normalizeHandle('left')).toBe('left')
expect(normalizeHandle('right-2')).toBe('right-2')
})
})
describe('removedHandleIds — all sides', () => {
it('left 3 → 0 removes left, left-2, left-3', () => {
expect(removedHandleIds('left', 3, 0)).toEqual(new Set(['left', 'left-2', 'left-3']))
})
it('top 2 → 1 removes only top-2, keeps slot 0', () => {
const removed = removedHandleIds('top', 2, 1)
expect(removed).toEqual(new Set(['top-2']))
expect(removed.has('top')).toBe(false)
})
it('bottom matches the legacy alias', () => {
expect(removedHandleIds('bottom', 4, 1)).toEqual(removedBottomHandleIds(4, 1))
})
})
describe('SIDES', () => {
it('lists all four sides', () => {
expect([...SIDES].sort()).toEqual(['bottom', 'left', 'right', 'top'])
})
})
+12 -3
View File
@@ -1,6 +1,6 @@
import type { Node, Edge } from '@xyflow/react' import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData, Waypoint } from '@/types' import type { NodeData, EdgeData, Waypoint } from '@/types'
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils' import { normalizeHandle, clampHandles } from '@/utils/handleUtils'
// ── Types ──────────────────────────────────────────────────────────────────── // ── Types ────────────────────────────────────────────────────────────────────
@@ -31,7 +31,10 @@ export interface ApiNode extends Record<string, unknown> {
properties?: unknown[] | null properties?: unknown[] | null
width?: number | null width?: number | null
height?: number | null height?: number | null
top_handles?: number
bottom_handles?: number bottom_handles?: number
left_handles?: number
right_handles?: number
show_port_numbers?: boolean show_port_numbers?: boolean
} }
@@ -117,7 +120,10 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
// fractional content-fit value. // fractional content-fit value.
width: n.width ?? n.measured?.width ?? null, width: n.width ?? n.measured?.width ?? null,
height: n.height ?? n.measured?.height ?? null, height: n.height ?? n.measured?.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), top_handles: clampHandles('top', n.data.top_handles ?? 1),
bottom_handles: clampHandles('bottom', n.data.bottom_handles ?? 1),
left_handles: clampHandles('left', n.data.left_handles ?? 0),
right_handles: clampHandles('right', n.data.right_handles ?? 0),
show_port_numbers: n.data.show_port_numbers ?? false, show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x, pos_x: n.position.x,
pos_y: n.position.y, pos_y: n.position.y,
@@ -178,7 +184,10 @@ export function deserializeApiNode(
data: { data: {
...n, ...n,
type: normalizedType, type: normalizedType,
bottom_handles: clampBottomHandles(n.bottom_handles ?? 1), top_handles: clampHandles('top', n.top_handles ?? 1),
bottom_handles: clampHandles('bottom', n.bottom_handles ?? 1),
left_handles: clampHandles('left', n.left_handles ?? 0),
right_handles: clampHandles('right', n.right_handles ?? 0),
collapsed: Boolean(n.custom_colors?.collapsed), collapsed: Boolean(n.custom_colors?.collapsed),
} as unknown as NodeData, } as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}), ...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
+90 -28
View File
@@ -1,39 +1,73 @@
/** /**
* Bottom handle configuration for multi-handle nodes. * Per-side connection-point (React Flow Handle) configuration.
* *
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible) * Handle IDs: slot 0 = the bare side name ('top' | 'bottom' | 'left' | 'right')
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64') * slot N ≥ 1 = '${side}-${N + 1}' (e.g. 'bottom-2', 'left-3', 'top-64')
*
* Slot 0 keeps the bare name so edges saved before the multi-handle expansion
* (which reference 'top' / 'bottom') stay valid — full backward compatibility.
* *
* Invisible target handles follow the same pattern with a '-t' suffix: * Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t' * 'bottom-t', 'bottom-2-t', 'left-t', 'right-3-t', ...
*/ */
export const MIN_BOTTOM_HANDLES = 1 import type { NodeData } from '@/types'
export const MAX_BOTTOM_HANDLES = 64
/** Returns the source handle ID at a given slot index. */ export type Side = 'top' | 'bottom' | 'left' | 'right'
export function bottomHandleId(idx: number): string {
return idx === 0 ? 'bottom' : `bottom-${idx + 1}` export const SIDES: readonly Side[] = ['top', 'bottom', 'left', 'right']
export const MAX_HANDLES = 64
// Back-compat aliases (bottom was the original, only, multi-handle side).
export const MIN_BOTTOM_HANDLES = 1
export const MAX_BOTTOM_HANDLES = MAX_HANDLES
/**
* Minimum (and default) count for a side.
* Top/Bottom default to 1 (their historical single handle); Left/Right default
* to 0 so existing diagrams gain no side handles unless the user opts in.
*/
export function sideDefault(side: Side): number {
return side === 'top' || side === 'bottom' ? 1 : 0
} }
/** Clamp a raw count into the supported range. Non-finite or non-int → MIN. */ /** The NodeData field name that stores a side's handle count. */
export function clampBottomHandles(n: unknown): number { export function handleCountField(side: Side): 'top_handles' | 'bottom_handles' | 'left_handles' | 'right_handles' {
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES return `${side}_handles` as 'top_handles' | 'bottom_handles' | 'left_handles' | 'right_handles'
}
/** Resolved connection-point count for a side (missing field → side default). */
export function sideHandleCount(data: NodeData, side: Side): number {
return clampHandles(side, data[handleCountField(side)] ?? sideDefault(side))
}
/** Returns the source handle ID at a given slot index for a side. */
export function handleId(side: Side, idx: number): string {
return idx === 0 ? side : `${side}-${idx + 1}`
}
/** Clamp a raw count into the supported range for a side (min = sideDefault, max = 64). */
export function clampHandles(side: Side, n: unknown): number {
const min = sideDefault(side)
if (typeof n !== 'number' || !Number.isFinite(n)) return min
const i = Math.floor(n) const i = Math.floor(n)
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES if (i < min) return min
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES if (i > MAX_HANDLES) return MAX_HANDLES
return i return i
} }
/** /**
* Left % positions for each handle slot. * Percentage offsets for each handle slot along the side's axis.
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel * Horizontal sides (top/bottom) → left %, vertical sides (left/right) → top %.
* Counts 1..4 keep the original hand-tuned values to preserve exact pixel
* positions on canvases saved before the multi-handle expansion. * positions on canvases saved before the multi-handle expansion.
* Counts ≥ 5 use uniform spacing. * Counts ≥ 5 use uniform spacing. Count 0 → [].
*/ */
export function bottomHandlePositions(count: number): number[] { export function handlePositions(side: Side, count: number): number[] {
const c = clampBottomHandles(count) const c = clampHandles(side, count)
switch (c) { switch (c) {
case 0: return []
case 1: return [50] case 1: return [50]
case 2: return [25, 75] case 2: return [25, 75]
case 3: return [20, 50, 80] case 3: return [20, 50, 80]
@@ -42,28 +76,56 @@ export function bottomHandlePositions(count: number): number[] {
} }
} }
/** True when a side lays out its handles vertically (offset is a top %). */
export function isVerticalSide(side: Side): boolean {
return side === 'left' || side === 'right'
}
/** /**
* Normalize a raw handle ID coming from a React Flow connection event. * Normalize a raw handle ID coming from a React Flow connection event.
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source * Invisible target handles (e.g. 'bottom-2-t', 'left-t') are mapped to their
* counterpart ('bottom-2') so the stored edge ID is stable and consistent. * source counterpart ('bottom-2', 'left') so the stored edge ID is stable.
*/ */
export function normalizeHandle(h: string | null | undefined): string | null { export function normalizeHandle(h: string | null | undefined): string | null {
if (!h) return null if (!h) return null
if (h === 'top-t') return 'top' const m = h.match(/^((?:top|bottom|left|right)(?:-\d+)?)-t$/)
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
if (m) return m[1] if (m) return m[1]
return h return h
} }
/** /**
* Returns the set of handle IDs that are removed when bottom_handles * Returns the set of source handle IDs removed when a side's count is reduced
* is reduced from `oldCount` to `newCount`. * from `oldCount` to `newCount`.
*/ */
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> { export function removedHandleIds(side: Side, oldCount: number, newCount: number): Set<string> {
const removed = new Set<string>() const removed = new Set<string>()
for (let i = newCount; i < oldCount; i++) { for (let i = newCount; i < oldCount; i++) {
removed.add(bottomHandleId(i)) removed.add(handleId(side, i))
} }
return removed return removed
} }
// ---------------------------------------------------------------------------
// Deprecated bottom-only aliases — kept so existing call sites don't churn.
// Prefer the side-generic functions above.
// ---------------------------------------------------------------------------
/** @deprecated use handleId('bottom', idx) */
export function bottomHandleId(idx: number): string {
return handleId('bottom', idx)
}
/** @deprecated use clampHandles('bottom', n) */
export function clampBottomHandles(n: unknown): number {
return clampHandles('bottom', n)
}
/** @deprecated use handlePositions('bottom', count) */
export function bottomHandlePositions(count: number): number[] {
return handlePositions('bottom', count)
}
/** @deprecated use removedHandleIds('bottom', oldCount, newCount) */
export function removedBottomHandleIds(oldCount: number, newCount: number): Set<string> {
return removedHandleIds('bottom', oldCount, newCount)
}