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
@@ -305,6 +305,26 @@ describe('deserializeApiNode — regular node', () => {
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', () => {
const map = new Map([['px1', true]])
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)