feat: add interactive edge waypoints with smooth path editing
- Drag waypoints to reshape edges; double-click a waypoint to remove it - + handles at segment midpoints to insert new waypoints - Bezier style: catmull-rom smooth curves through waypoints - Smooth style: rounded-corner polyline with soft 45° snap (snaps within 15px) - First + handle biased to source axis for perpendicular node exit - snap45both: ray-intersection solver ensures both adjacent segments snap to 45° simultaneously - Clear path button in EdgeModal when waypoints exist - Waypoints serialised/deserialised with canvas state
This commit is contained in:
@@ -23,11 +23,12 @@ interface EdgeModalProps {
|
||||
onClose: () => void
|
||||
onSubmit: (data: EdgeData) => void
|
||||
onDelete?: () => void
|
||||
onClearWaypoints?: () => void
|
||||
initial?: Partial<EdgeData>
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints, initial, title = 'Connect Nodes' }: EdgeModalProps) {
|
||||
const [type, setType] = useState<EdgeType>(initial?.type ?? 'ethernet')
|
||||
const [label, setLabel] = useState(initial?.label ?? '')
|
||||
const [vlanId, setVlanId] = useState(initial?.vlan_id?.toString() ?? '')
|
||||
@@ -175,6 +176,16 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, initial, title =
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{onClearWaypoints && initial?.waypoints && initial.waypoints.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onClearWaypoints(); onClose() }}
|
||||
className="text-[10px] text-muted-foreground hover:text-[#e3b341] transition-colors text-left"
|
||||
>
|
||||
Clear path ({initial.waypoints.length} point{initial.waypoints.length !== 1 ? 's' : ''})
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-1">
|
||||
{onDelete ? (
|
||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
||||
|
||||
@@ -187,4 +187,55 @@ describe('EdgeModal', () => {
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Waypoints / Clear path ────────────────────────────────────────────────
|
||||
|
||||
it('does not show Clear path button when onClearWaypoints is not provided', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when waypoints are empty', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} initial={{ type: 'ethernet', waypoints: [] }} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show Clear path button when no initial waypoints', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()} />)
|
||||
expect(screen.queryByText(/Clear path/)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Clear path button with count when waypoints exist', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (2 points)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows singular "point" when only one waypoint', () => {
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={vi.fn()} onSubmit={vi.fn()} onClearWaypoints={vi.fn()}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Clear path (1 point)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onClearWaypoints and onClose when Clear path is clicked', () => {
|
||||
const onClearWaypoints = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<EdgeModal
|
||||
open onClose={onClose} onSubmit={vi.fn()} onClearWaypoints={onClearWaypoints}
|
||||
initial={{ type: 'ethernet', waypoints: [{ x: 1, y: 2 }] }}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('Clear path (1 point)'))
|
||||
expect(onClearWaypoints).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user