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,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