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:
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
DEFAULT_ALIGNMENT_SETTINGS,
|
||||
readAlignmentSettings,
|
||||
writeAlignmentSettings,
|
||||
subscribeAlignmentSettings,
|
||||
} from '../alignmentSettings'
|
||||
|
||||
describe('alignmentSettings', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns defaults when nothing stored', () => {
|
||||
expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
|
||||
})
|
||||
|
||||
it('roundtrips through localStorage', () => {
|
||||
writeAlignmentSettings({ enabled: false, threshold: 10 })
|
||||
expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: 10 })
|
||||
})
|
||||
|
||||
it('falls back to defaults when stored value is corrupted', () => {
|
||||
localStorage.setItem('homelable.alignmentGuides', '{not json')
|
||||
expect(readAlignmentSettings()).toEqual(DEFAULT_ALIGNMENT_SETTINGS)
|
||||
})
|
||||
|
||||
it('fills missing fields from defaults', () => {
|
||||
localStorage.setItem('homelable.alignmentGuides', JSON.stringify({ enabled: false }))
|
||||
expect(readAlignmentSettings()).toEqual({ enabled: false, threshold: DEFAULT_ALIGNMENT_SETTINGS.threshold })
|
||||
})
|
||||
|
||||
it('notifies subscribers on write', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeAlignmentSettings(listener)
|
||||
writeAlignmentSettings({ enabled: false, threshold: 8 })
|
||||
expect(listener).toHaveBeenCalledWith({ enabled: false, threshold: 8 })
|
||||
unsubscribe()
|
||||
writeAlignmentSettings({ enabled: true, threshold: 4 })
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user