feat(canvas): alignment guides + snap while dragging nodes

Draw.io / Figma style: while dragging a node, show dashed cyan guide
lines when its edges (left / center / right / top / middle / bottom)
align with another node's, and snap the position to the matched line
within a configurable threshold.

- utils/alignment.ts: pure snap math, returns delta + guide segments.
  Same-size boxes show all aligned guides simultaneously.
- canvas/AlignmentGuides.tsx: SVG overlay locked to the React Flow
  viewport (panned/zoomed correctly).
- hooks/useAlignmentGuides.ts: wires onNodeDrag/onNodeDragStop, applies
  snap via setNodes, listens for Alt to temporarily disable.
- utils/alignmentSettings.ts: localStorage-backed prefs (enabled,
  threshold 2-16px) with a tiny CustomEvent pub-sub so the SettingsPanel
  and the drag hook stay in sync without a global store.
- Sidebar settings panel: toggle + threshold slider.

Multi-selection drag uses the union bounding box. Children with
parentId are skipped for v1 to avoid mixing absolute and parent-relative
coordinates. Hold Alt to escape snap.
This commit is contained in:
Pouzor
2026-05-11 00:35:50 +02:00
parent 5ab0bdeb7f
commit 004a8f19c1
9 changed files with 627 additions and 0 deletions
@@ -0,0 +1,70 @@
import { useViewport } from '@xyflow/react'
import type { Guide } from '@/utils/alignment'
interface AlignmentGuidesProps {
guides: Guide[]
color?: string
}
/**
* SVG overlay that draws alignment guide lines on top of the React Flow canvas.
* Coordinates are in canvas (flow) space; we read the viewport transform to
* project them into screen space so lines stay locked to nodes when the user
* pans or zooms.
*/
export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
const { x: vx, y: vy, zoom } = useViewport()
if (guides.length === 0) return null
return (
<svg
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 5,
overflow: 'visible',
}}
>
{guides.map((g, i) => {
if (g.axis === 'x') {
const x = g.position * zoom + vx
const y1 = g.start * zoom + vy
const y2 = g.end * zoom + vy
return (
<line
key={`x-${i}-${g.position}`}
x1={x}
y1={y1}
x2={x}
y2={y2}
stroke={color}
strokeWidth={1}
strokeDasharray="4 3"
shapeRendering="crispEdges"
/>
)
}
const y = g.position * zoom + vy
const x1 = g.start * zoom + vx
const x2 = g.end * zoom + vx
return (
<line
key={`y-${i}-${g.position}`}
x1={x1}
y1={y}
x2={x2}
y2={y}
stroke={color}
strokeWidth={1}
strokeDasharray="4 3"
shapeRendering="crispEdges"
/>
)
})}
</svg>
)
}
@@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import { AlignmentGuides } from './AlignmentGuides'
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
[]
)
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
@@ -96,6 +100,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
deleteKeyCode={['Backspace', 'Delete']}
@@ -121,6 +127,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
color={theme.colors.canvasDotColor}
/>
<SearchBar onOpenPending={onOpenPending} />
<AlignmentGuides guides={guides} />
<Controls>
<ControlButton
onClick={() => setLassoMode((m) => !m)}
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest'
import { render } from '@testing-library/react'
import { AlignmentGuides } from '../AlignmentGuides'
import type { Guide } from '@/utils/alignment'
vi.mock('@xyflow/react', () => ({
useViewport: () => ({ x: 50, y: 100, zoom: 2 }),
}))
describe('AlignmentGuides', () => {
it('renders nothing when no guides', () => {
const { container } = render(<AlignmentGuides guides={[]} />)
expect(container.querySelector('svg')).toBeNull()
})
it('projects an x-axis guide through the viewport transform', () => {
const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }]
const { container } = render(<AlignmentGuides guides={guides} />)
const line = container.querySelector('line')!
// x = position * zoom + vx → 100*2 + 50 = 250
expect(line.getAttribute('x1')).toBe('250')
expect(line.getAttribute('x2')).toBe('250')
// y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500
expect(line.getAttribute('y1')).toBe('100')
expect(line.getAttribute('y2')).toBe('500')
})
it('projects a y-axis guide horizontally', () => {
const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }]
const { container } = render(<AlignmentGuides guides={guides} />)
const line = container.querySelector('line')!
// y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170
expect(line.getAttribute('y1')).toBe('200')
expect(line.getAttribute('y2')).toBe('200')
expect(line.getAttribute('x1')).toBe('70')
expect(line.getAttribute('x2')).toBe('170')
})
it('renders one line per guide', () => {
const guides: Guide[] = [
{ axis: 'x', position: 100, start: 0, end: 200 },
{ axis: 'y', position: 50, start: 10, end: 60 },
]
const { container } = render(<AlignmentGuides guides={guides} />)
expect(container.querySelectorAll('line')).toHaveLength(2)
})
})