feat: selectable marker shapes per edge endpoint

Replace the on/off arrowhead toggle with a per-end shape picker. Each end
(start/end) independently selects: none, arrow, arrow-open, circle, diamond,
or square. Markers still recolor live from the resolved stroke color.

Frontend:
- MarkerShape type + edgeMarkers util (normalizeMarker, MARKER_GEOMETRY);
  legacy boolean coerces to 'arrow' on read.
- Per-shape <marker> inner geometry; symmetric shapes use fixed orient.
- MarkerShapePicker reused in EdgeModal and CustomStyleModal.
- Serializer normalizes to shape strings.

Backend:
- Edge marker columns Boolean -> String (default 'none'); TEXT migration.
- normalize_marker() + validators coerce legacy bool / unknown values.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-07-05 11:30:49 +02:00
parent 1cf525844b
commit c95d104245
20 changed files with 385 additions and 157 deletions
@@ -124,26 +124,26 @@ describe('CustomStyleModal', () => {
expect(markUnsaved).not.toHaveBeenCalled()
})
it('edge editor exposes Start/End arrow toggles defaulting off', () => {
it('edge editor exposes Start/End marker pickers defaulting to none', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
const startBtn = screen.getByRole('button', { name: 'Start' })
const endBtn = screen.getByRole('button', { name: 'End' })
expect(startBtn.getAttribute('aria-pressed')).toBe('false')
expect(endBtn.getAttribute('aria-pressed')).toBe('false')
const startNone = screen.getByRole('button', { name: 'Start marker none' })
const endNone = screen.getByRole('button', { name: 'End marker none' })
expect(startNone.getAttribute('aria-pressed')).toBe('true')
expect(endNone.getAttribute('aria-pressed')).toBe('true')
})
it('toggling End arrow feeds arrowEnd to applyTypeEdgeStyle', () => {
it('picking an End shape feeds arrowEnd to applyTypeEdgeStyle', () => {
const applyTypeEdgeStyle = vi.fn()
useCanvasStore.setState({ applyTypeEdgeStyle })
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
fireEvent.click(screen.getByRole('button', { name: 'End' }))
fireEvent.click(screen.getByRole('button', { name: 'End marker diamond' }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true)
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false)
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond')
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none')
})
it('editing path style updates the edge draft', () => {
@@ -165,39 +165,56 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
})
// ── Arrow markers ─────────────────────────────────────────────────────────
// ── Endpoint markers ──────────────────────────────────────────────────────
it('arrows default to off', () => {
it('endpoints default to none', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
expect(onSubmit.mock.calls[0][0].marker_end).toBe(false)
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
expect(onSubmit.mock.calls[0][0].marker_end).toBe('none')
})
it('toggling End arrow sends marker_end: true', () => {
it('picking an End arrow sends marker_end: "arrow"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: /Arrow End/ }))
fireEvent.click(screen.getByRole('button', { name: 'End marker arrow' }))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
})
it('toggling Start arrow sends marker_start: true', () => {
it('picking a Start circle sends marker_start: "circle"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ }))
fireEvent.click(screen.getByRole('button', { name: 'Start marker circle' }))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
expect(onSubmit.mock.calls[0][0].marker_start).toBe('circle')
})
it('pre-fills arrows from initial', () => {
it('allows a different shape on each end', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: true, marker_end: true }} />)
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.click(screen.getByRole('button', { name: 'Start marker diamond' }))
fireEvent.click(screen.getByRole('button', { name: 'End marker square' }))
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
expect(onSubmit.mock.calls[0][0].marker_end).toBe('square')
})
it('pre-fills endpoint shapes from initial', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: 'diamond', marker_end: 'arrow-open' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow-open')
})
it('coerces a legacy boolean initial marker to "arrow"', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_end: true }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
})
it('selecting None after Snake omits animated from payload', () => {