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,110 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeSnap, unionBox, type Box } from '../alignment'
|
||||
|
||||
const box = (id: string, x: number, y: number, width = 100, height = 50): Box => ({ id, x, y, width, height })
|
||||
|
||||
describe('computeSnap', () => {
|
||||
it('returns zero deltas with no candidates', () => {
|
||||
const r = computeSnap(box('a', 0, 0), [], 6)
|
||||
expect(r).toEqual({ deltaX: 0, deltaY: 0, guides: [] })
|
||||
})
|
||||
|
||||
it('snaps left edges when within threshold (same-size boxes show all aligned guides)', () => {
|
||||
const dragged = box('a', 102, 200) // left=102
|
||||
const other = box('b', 100, 0) // left=100 → delta -2 → all three edges tie
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
expect(r.deltaX).toBe(-2)
|
||||
expect(r.deltaY).toBe(0)
|
||||
// Same width: left/center/right all tie at delta -2. Draw all three guides.
|
||||
expect(r.guides.map((g) => g.position).sort((a, b) => a - b)).toEqual([100, 150, 200])
|
||||
expect(r.guides.every((g) => g.axis === 'x')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a single guide when only one edge aligns', () => {
|
||||
const dragged = box('a', 102, 200, 80, 50) // left=102, center=142, right=182
|
||||
const other = box('b', 100, 0, 100, 50) // left=100, center=150, right=200
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
expect(r.deltaX).toBe(-2) // left↔left wins
|
||||
expect(r.guides).toHaveLength(1)
|
||||
expect(r.guides[0].position).toBe(100)
|
||||
})
|
||||
|
||||
it('does not snap when outside threshold', () => {
|
||||
const dragged = box('a', 110, 200)
|
||||
const other = box('b', 100, 0)
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
expect(r.deltaX).toBe(0)
|
||||
expect(r.guides).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('snaps centerX to centerX', () => {
|
||||
const dragged = box('a', 51, 300, 100, 50) // center=101
|
||||
const other = box('b', 75, 0, 50, 50) // center=100 → delta -1
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
expect(r.deltaX).toBe(-1)
|
||||
expect(r.guides).toHaveLength(1)
|
||||
expect(r.guides[0].position).toBe(100)
|
||||
})
|
||||
|
||||
it('snaps right edge of dragged to left edge of candidate', () => {
|
||||
const dragged = box('a', 0, 200) // right=100
|
||||
const other = box('b', 102, 0) // left=102 → delta +2
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
expect(r.deltaX).toBe(2)
|
||||
})
|
||||
|
||||
it('snaps both axes simultaneously', () => {
|
||||
const dragged = box('a', 102, 53, 80, 30)
|
||||
const other = box('b', 100, 50, 100, 50)
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
expect(r.deltaX).toBe(-2)
|
||||
expect(r.deltaY).toBe(-3)
|
||||
// Distinct sizes → exactly one X guide and one Y guide.
|
||||
expect(r.guides.filter((g) => g.axis === 'x')).toHaveLength(1)
|
||||
expect(r.guides.filter((g) => g.axis === 'y')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('picks the closest match when multiple candidates compete', () => {
|
||||
const dragged = box('a', 103, 0)
|
||||
const closer = box('b', 100, 0) // delta -3
|
||||
const farther = box('c', 109, 0) // delta +6 — also within threshold but worse
|
||||
const r = computeSnap(dragged, [closer, farther], 6)
|
||||
expect(r.deltaX).toBe(-3)
|
||||
})
|
||||
|
||||
it('ignores the dragged box itself when present in candidates', () => {
|
||||
const dragged = box('a', 102, 0)
|
||||
const r = computeSnap(dragged, [dragged, box('b', 100, 0)], 6)
|
||||
expect(r.deltaX).toBe(-2)
|
||||
})
|
||||
|
||||
it('returns no snap with non-positive threshold', () => {
|
||||
const r = computeSnap(box('a', 100, 0), [box('b', 100, 0)], 0)
|
||||
expect(r.guides).toEqual([])
|
||||
})
|
||||
|
||||
it('guide span covers both involved boxes', () => {
|
||||
const dragged = box('a', 102, 200, 100, 50) // y range 200..250
|
||||
const other = box('b', 100, 0, 100, 50) // y range 0..50
|
||||
const r = computeSnap(dragged, [other], 6)
|
||||
const g = r.guides[0]
|
||||
expect(g.start).toBeLessThanOrEqual(0)
|
||||
expect(g.end).toBeGreaterThanOrEqual(250)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unionBox', () => {
|
||||
it('returns null for empty input', () => {
|
||||
expect(unionBox([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the box itself for one input', () => {
|
||||
const b = box('a', 10, 20, 30, 40)
|
||||
expect(unionBox([b])).toMatchObject({ x: 10, y: 20, width: 30, height: 40 })
|
||||
})
|
||||
|
||||
it('computes union of multiple boxes', () => {
|
||||
const u = unionBox([box('a', 0, 0, 50, 50), box('b', 100, 100, 50, 50)])
|
||||
expect(u).toMatchObject({ x: 0, y: 0, width: 150, height: 150 })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// Alignment-snap math for drag-time guides (draw.io / Figma style).
|
||||
//
|
||||
// Given a dragged box and a set of candidate boxes, return the snap delta
|
||||
// (clamped by threshold, expressed in canvas px) plus the guide segments that
|
||||
// should be drawn while the snap is engaged.
|
||||
//
|
||||
// All coordinates are absolute canvas coordinates — callers must resolve
|
||||
// child-of-parent positions before calling.
|
||||
|
||||
export interface Box {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type GuideAxis = 'x' | 'y'
|
||||
|
||||
export interface Guide {
|
||||
axis: GuideAxis
|
||||
// Position along the perpendicular axis (x for vertical guide, y for horizontal).
|
||||
position: number
|
||||
// Span along the parallel axis: min/max of involved boxes' opposite-axis extents.
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
deltaX: number
|
||||
deltaY: number
|
||||
guides: Guide[]
|
||||
}
|
||||
|
||||
type EdgeKind = 'min' | 'center' | 'max'
|
||||
|
||||
interface AxisLine {
|
||||
kind: EdgeKind
|
||||
pos: number
|
||||
// Span of the source box along the perpendicular axis (used to extend guide segments).
|
||||
spanStart: number
|
||||
spanEnd: number
|
||||
}
|
||||
|
||||
function xLines(box: Box): AxisLine[] {
|
||||
const top = box.y
|
||||
const bottom = box.y + box.height
|
||||
return [
|
||||
{ kind: 'min', pos: box.x, spanStart: top, spanEnd: bottom },
|
||||
{ kind: 'center', pos: box.x + box.width / 2, spanStart: top, spanEnd: bottom },
|
||||
{ kind: 'max', pos: box.x + box.width, spanStart: top, spanEnd: bottom },
|
||||
]
|
||||
}
|
||||
|
||||
function yLines(box: Box): AxisLine[] {
|
||||
const left = box.x
|
||||
const right = box.x + box.width
|
||||
return [
|
||||
{ kind: 'min', pos: box.y, spanStart: left, spanEnd: right },
|
||||
{ kind: 'center', pos: box.y + box.height / 2, spanStart: left, spanEnd: right },
|
||||
{ kind: 'max', pos: box.y + box.height, spanStart: left, spanEnd: right },
|
||||
]
|
||||
}
|
||||
|
||||
interface Match {
|
||||
delta: number
|
||||
guidePos: number
|
||||
candidate: AxisLine
|
||||
dragLine: AxisLine
|
||||
}
|
||||
|
||||
// Find all (dragLine, candidateLine) pairs at the smallest |delta| within threshold.
|
||||
// When boxes share dimensions, all three edges align simultaneously — return all.
|
||||
function bestMatches(dragLines: AxisLine[], candidateLines: AxisLine[], threshold: number): Match[] {
|
||||
let bestAbs = Infinity
|
||||
const matches: Match[] = []
|
||||
for (const d of dragLines) {
|
||||
for (const c of candidateLines) {
|
||||
const delta = c.pos - d.pos
|
||||
const abs = Math.abs(delta)
|
||||
if (abs > threshold) continue
|
||||
if (abs < bestAbs - 1e-9) {
|
||||
bestAbs = abs
|
||||
matches.length = 0
|
||||
}
|
||||
if (abs <= bestAbs + 1e-9) {
|
||||
matches.push({ delta, guidePos: c.pos, candidate: c, dragLine: d })
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute snap delta + visible guides for a drag operation.
|
||||
*
|
||||
* @param dragged Bounding box of the dragged node (or selection bbox).
|
||||
* @param candidates Other nodes' boxes to consider.
|
||||
* @param threshold Max distance (canvas px) at which a snap engages.
|
||||
*/
|
||||
export function computeSnap(dragged: Box, candidates: Box[], threshold: number): SnapResult {
|
||||
if (candidates.length === 0 || threshold <= 0) {
|
||||
return { deltaX: 0, deltaY: 0, guides: [] }
|
||||
}
|
||||
|
||||
const dragX = xLines(dragged)
|
||||
const dragY = yLines(dragged)
|
||||
|
||||
let bestXMatches: Match[] = []
|
||||
let bestYMatches: Match[] = []
|
||||
let xBestAbs = Infinity
|
||||
let yBestAbs = Infinity
|
||||
|
||||
for (const cand of candidates) {
|
||||
if (cand.id === dragged.id) continue
|
||||
const mx = bestMatches(dragX, xLines(cand), threshold)
|
||||
if (mx.length > 0) {
|
||||
const abs = Math.abs(mx[0].delta)
|
||||
if (abs < xBestAbs - 1e-9) {
|
||||
xBestAbs = abs
|
||||
bestXMatches = mx
|
||||
} else if (abs <= xBestAbs + 1e-9) {
|
||||
bestXMatches = bestXMatches.concat(mx)
|
||||
}
|
||||
}
|
||||
const my = bestMatches(dragY, yLines(cand), threshold)
|
||||
if (my.length > 0) {
|
||||
const abs = Math.abs(my[0].delta)
|
||||
if (abs < yBestAbs - 1e-9) {
|
||||
yBestAbs = abs
|
||||
bestYMatches = my
|
||||
} else if (abs <= yBestAbs + 1e-9) {
|
||||
bestYMatches = bestYMatches.concat(my)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deltaX = bestXMatches[0]?.delta ?? 0
|
||||
const deltaY = bestYMatches[0]?.delta ?? 0
|
||||
|
||||
const guides: Guide[] = []
|
||||
const seenX = new Set<number>()
|
||||
for (const m of bestXMatches) {
|
||||
if (seenX.has(m.guidePos)) continue
|
||||
seenX.add(m.guidePos)
|
||||
const start = Math.min(m.dragLine.spanStart + deltaY, m.candidate.spanStart)
|
||||
const end = Math.max(m.dragLine.spanEnd + deltaY, m.candidate.spanEnd)
|
||||
guides.push({ axis: 'x', position: m.guidePos, start, end })
|
||||
}
|
||||
const seenY = new Set<number>()
|
||||
for (const m of bestYMatches) {
|
||||
if (seenY.has(m.guidePos)) continue
|
||||
seenY.add(m.guidePos)
|
||||
const start = Math.min(m.dragLine.spanStart + deltaX, m.candidate.spanStart)
|
||||
const end = Math.max(m.dragLine.spanEnd + deltaX, m.candidate.spanEnd)
|
||||
guides.push({ axis: 'y', position: m.guidePos, start, end })
|
||||
}
|
||||
|
||||
return { deltaX, deltaY, guides }
|
||||
}
|
||||
|
||||
/** Bounding box of multiple boxes — used for multi-selection drag. */
|
||||
export function unionBox(boxes: Box[]): Box | null {
|
||||
if (boxes.length === 0) return null
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (const b of boxes) {
|
||||
if (b.x < minX) minX = b.x
|
||||
if (b.y < minY) minY = b.y
|
||||
if (b.x + b.width > maxX) maxX = b.x + b.width
|
||||
if (b.y + b.height > maxY) maxY = b.y + b.height
|
||||
}
|
||||
return { id: '__union__', x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Persisted client-side settings for alignment guides.
|
||||
// Kept in localStorage (per-user UI preference, not canvas data).
|
||||
// Same-tab updates propagate via a CustomEvent so the drag hook and the
|
||||
// settings panel can stay in sync without a global store.
|
||||
|
||||
export interface AlignmentSettings {
|
||||
enabled: boolean
|
||||
threshold: number
|
||||
}
|
||||
|
||||
export const DEFAULT_ALIGNMENT_SETTINGS: AlignmentSettings = { enabled: true, threshold: 6 }
|
||||
|
||||
const KEY = 'homelable.alignmentGuides'
|
||||
const EVENT = 'homelable:alignment-settings-changed'
|
||||
|
||||
export function readAlignmentSettings(): AlignmentSettings {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return DEFAULT_ALIGNMENT_SETTINGS
|
||||
const parsed = JSON.parse(raw) as Partial<AlignmentSettings>
|
||||
return {
|
||||
enabled: parsed.enabled ?? DEFAULT_ALIGNMENT_SETTINGS.enabled,
|
||||
threshold: typeof parsed.threshold === 'number' ? parsed.threshold : DEFAULT_ALIGNMENT_SETTINGS.threshold,
|
||||
}
|
||||
} catch {
|
||||
return DEFAULT_ALIGNMENT_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAlignmentSettings(s: AlignmentSettings): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(s))
|
||||
window.dispatchEvent(new CustomEvent<AlignmentSettings>(EVENT, { detail: s }))
|
||||
} catch {
|
||||
/* quota / SSR */
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeAlignmentSettings(listener: (s: AlignmentSettings) => void): () => void {
|
||||
const handler = (e: Event) => listener((e as CustomEvent<AlignmentSettings>).detail)
|
||||
window.addEventListener(EVENT, handler)
|
||||
return () => window.removeEventListener(EVENT, handler)
|
||||
}
|
||||
Reference in New Issue
Block a user