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
@@ -1184,6 +1184,60 @@ describe('canvasStore', () => {
expect(after.find((e) => e.id === 'e3')?.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', () => {
+20 -16
View File
@@ -11,7 +11,7 @@ import {
} from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
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 { readHideIp, writeHideIp } from '@/utils/ipDisplay'
@@ -357,22 +357,26 @@ export const useCanvasStore = create<CanvasState>((set) => ({
const children = nodes.filter((n) => !!n.parentId)
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
if ('bottom_handles' in data && data.bottom_handles != null) {
const currentNode = state.nodes.find((n) => n.id === id)
const oldCount = currentNode?.data.bottom_handles ?? 1
const newCount = data.bottom_handles
if (newCount < oldCount) {
const removed = removedBottomHandleIds(oldCount, newCount)
edges = state.edges.map((e) => {
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
return { ...e, sourceHandle: 'bottom' }
if (e.target === id && e.targetHandle && removed.has(e.targetHandle))
return { ...e, targetHandle: 'bottom' }
return e
})
}
const currentNode = state.nodes.find((n) => n.id === id)
for (const side of SIDES) {
const field = handleCountField(side)
if (!(field in data) || data[field] == null) continue
const oldCount = currentNode?.data[field] ?? sideDefault(side)
const newCount = data[field] as number
if (newCount >= oldCount) continue
const removed = removedHandleIds(side, oldCount, newCount)
const fallback = newCount === 0 ? 'bottom' : handleId(side, 0)
edges = edges.map((e) => {
if (e.source === id && e.sourceHandle && removed.has(e.sourceHandle))
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 }