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:
@@ -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)
|
||||
|
||||
@@ -2,11 +2,20 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
MIN_BOTTOM_HANDLES,
|
||||
MAX_BOTTOM_HANDLES,
|
||||
MAX_HANDLES,
|
||||
bottomHandleId,
|
||||
bottomHandlePositions,
|
||||
clampBottomHandles,
|
||||
normalizeHandle,
|
||||
removedBottomHandleIds,
|
||||
handleId,
|
||||
handlePositions,
|
||||
clampHandles,
|
||||
removedHandleIds,
|
||||
sideDefault,
|
||||
handleCountField,
|
||||
isVerticalSide,
|
||||
SIDES,
|
||||
} from '../handleUtils'
|
||||
|
||||
describe('bottomHandleId', () => {
|
||||
@@ -176,3 +185,125 @@ describe('removedBottomHandleIds', () => {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData, Waypoint } from '@/types'
|
||||
import { normalizeHandle, clampBottomHandles } from '@/utils/handleUtils'
|
||||
import { normalizeHandle, clampHandles } from '@/utils/handleUtils'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,7 +31,10 @@ export interface ApiNode extends Record<string, unknown> {
|
||||
properties?: unknown[] | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
top_handles?: number
|
||||
bottom_handles?: number
|
||||
left_handles?: number
|
||||
right_handles?: number
|
||||
show_port_numbers?: boolean
|
||||
}
|
||||
|
||||
@@ -117,7 +120,10 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
// fractional content-fit value.
|
||||
width: n.width ?? n.measured?.width ?? 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,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
@@ -178,7 +184,10 @@ export function deserializeApiNode(
|
||||
data: {
|
||||
...n,
|
||||
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),
|
||||
} as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
|
||||
@@ -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)
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
|
||||
* Handle IDs: slot 0 = the bare side name ('top' | 'bottom' | 'left' | 'right')
|
||||
* 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:
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
|
||||
* 'bottom-t', 'bottom-2-t', 'left-t', 'right-3-t', ...
|
||||
*/
|
||||
|
||||
export const MIN_BOTTOM_HANDLES = 1
|
||||
export const MAX_BOTTOM_HANDLES = 64
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
/** Returns the source handle ID at a given slot index. */
|
||||
export function bottomHandleId(idx: number): string {
|
||||
return idx === 0 ? 'bottom' : `bottom-${idx + 1}`
|
||||
export type Side = 'top' | 'bottom' | 'left' | 'right'
|
||||
|
||||
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. */
|
||||
export function clampBottomHandles(n: unknown): number {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n)) return MIN_BOTTOM_HANDLES
|
||||
/** The NodeData field name that stores a side's handle count. */
|
||||
export function handleCountField(side: Side): 'top_handles' | 'bottom_handles' | 'left_handles' | 'right_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)
|
||||
if (i < MIN_BOTTOM_HANDLES) return MIN_BOTTOM_HANDLES
|
||||
if (i > MAX_BOTTOM_HANDLES) return MAX_BOTTOM_HANDLES
|
||||
if (i < min) return min
|
||||
if (i > MAX_HANDLES) return MAX_HANDLES
|
||||
return i
|
||||
}
|
||||
|
||||
/**
|
||||
* Left % positions for each handle slot.
|
||||
* Counts 1..4 keep their original hand-tuned values to preserve exact pixel
|
||||
* Percentage offsets for each handle slot along the side's axis.
|
||||
* 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.
|
||||
* Counts ≥ 5 use uniform spacing.
|
||||
* Counts ≥ 5 use uniform spacing. Count 0 → [].
|
||||
*/
|
||||
export function bottomHandlePositions(count: number): number[] {
|
||||
const c = clampBottomHandles(count)
|
||||
export function handlePositions(side: Side, count: number): number[] {
|
||||
const c = clampHandles(side, count)
|
||||
switch (c) {
|
||||
case 0: return []
|
||||
case 1: return [50]
|
||||
case 2: return [25, 75]
|
||||
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.
|
||||
* Invisible target handles (e.g. 'bottom-2-t') are mapped to their source
|
||||
* counterpart ('bottom-2') so the stored edge ID is stable and consistent.
|
||||
* Invisible target handles (e.g. 'bottom-2-t', 'left-t') are mapped to their
|
||||
* source counterpart ('bottom-2', 'left') so the stored edge ID is stable.
|
||||
*/
|
||||
export function normalizeHandle(h: string | null | undefined): string | null {
|
||||
if (!h) return null
|
||||
if (h === 'top-t') return 'top'
|
||||
// 'bottom-t' → 'bottom', 'bottom-2-t' → 'bottom-2', etc.
|
||||
const m = h.match(/^(bottom(?:-\d+)?)-t$/)
|
||||
const m = h.match(/^((?:top|bottom|left|right)(?:-\d+)?)-t$/)
|
||||
if (m) return m[1]
|
||||
return h
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of handle IDs that are removed when bottom_handles
|
||||
* is reduced from `oldCount` to `newCount`.
|
||||
* Returns the set of source handle IDs removed when a side's count is reduced
|
||||
* 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>()
|
||||
for (let i = newCount; i < oldCount; i++) {
|
||||
removed.add(bottomHandleId(i))
|
||||
removed.add(handleId(side, i))
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user