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
@@ -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>
)
})
})}
</>
)
}