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