Merge pull request #139 from Pouzor/feature/node-alignment-guides
feat(canvas): alignment guides + snap while dragging
This commit is contained in:
@@ -248,14 +248,16 @@ export default function App() {
|
|||||||
const id = generateUUID()
|
const id = generateUUID()
|
||||||
const newNode: Node<NodeData> = {
|
const newNode: Node<NodeData> = {
|
||||||
id,
|
id,
|
||||||
|
// Text lives in `label` because the API serializer only persists top-level
|
||||||
|
// node fields; text_content is not in the schema and was lost on reload.
|
||||||
|
// TextNode and the edit modal both already fall back to label.
|
||||||
type: 'text',
|
type: 'text',
|
||||||
position: { x: 250, y: 250 },
|
position: { x: 250, y: 250 },
|
||||||
data: {
|
data: {
|
||||||
label: '',
|
label: data.text,
|
||||||
type: 'text',
|
type: 'text',
|
||||||
status: 'unknown',
|
status: 'unknown',
|
||||||
services: [],
|
services: [],
|
||||||
text_content: data.text,
|
|
||||||
custom_colors: {
|
custom_colors: {
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
border_style: data.border_style,
|
border_style: data.border_style,
|
||||||
@@ -277,7 +279,10 @@ export default function App() {
|
|||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const existing = nodes.find((n) => n.id === editingTextId)
|
const existing = nodes.find((n) => n.id === editingTextId)
|
||||||
updateNode(editingTextId, {
|
updateNode(editingTextId, {
|
||||||
text_content: data.text,
|
label: data.text,
|
||||||
|
// Clear stale text_content if present from older builds — label is the
|
||||||
|
// source of truth now.
|
||||||
|
text_content: undefined,
|
||||||
custom_colors: {
|
custom_colors: {
|
||||||
...existing?.data.custom_colors,
|
...existing?.data.custom_colors,
|
||||||
border: data.border_color,
|
border: data.border_color,
|
||||||
|
|||||||
@@ -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 { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
|
import { AlignmentGuides } from './AlignmentGuides'
|
||||||
|
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
@@ -96,6 +100,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
@@ -121,6 +127,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<SearchBar onOpenPending={onOpenPending} />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
|
<AlignmentGuides guides={guides} />
|
||||||
<Controls>
|
<Controls>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onClick={() => setLassoMode((m) => !m)}
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -7,6 +7,12 @@ import { useAuthStore } from '@/stores/authStore'
|
|||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
@@ -321,6 +327,7 @@ function ScanHistoryPanel() {
|
|||||||
function SettingsPanel() {
|
function SettingsPanel() {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
@@ -328,6 +335,14 @@ function SettingsPanel() {
|
|||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
|
||||||
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
|
const next = { ...alignment, ...patch }
|
||||||
|
setAlignment(next)
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -369,6 +384,41 @@ function SettingsPanel() {
|
|||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alignment.enabled}
|
||||||
|
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle alignment guides"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={2}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
value={alignment.threshold}
|
||||||
|
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||||
|
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Alignment snap threshold"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useReactFlow, type Node, type OnNodeDrag } from '@xyflow/react'
|
||||||
|
import { computeSnap, unionBox, type Box, type Guide } from '@/utils/alignment'
|
||||||
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
type NodeDrag = OnNodeDrag<Node<NodeData>>
|
||||||
|
|
||||||
|
function nodeBox(n: Node<NodeData>): Box | null {
|
||||||
|
// Skip parented nodes — alignment between absolute and parent-relative
|
||||||
|
// coordinates is misleading. Top-level nodes only for v1.
|
||||||
|
if (n.parentId) return null
|
||||||
|
const width = n.measured?.width ?? n.width ?? null
|
||||||
|
const height = n.measured?.height ?? n.height ?? null
|
||||||
|
if (width == null || height == null) return null
|
||||||
|
return { id: n.id, x: n.position.x, y: n.position.y, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag-time alignment guides + snap (draw.io / Figma style).
|
||||||
|
*
|
||||||
|
* Snap is applied on drag stop (not during drag) to avoid racing React Flow's
|
||||||
|
* internal drag handler, which computes positions from the cursor offset
|
||||||
|
* captured at drag start. Guides update live for visual feedback.
|
||||||
|
*
|
||||||
|
* Settings (enabled, threshold) live in localStorage and propagate same-tab
|
||||||
|
* via a CustomEvent so the panel and the hook stay in sync.
|
||||||
|
*/
|
||||||
|
export function useAlignmentGuides() {
|
||||||
|
const [settings, setSettings] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const [guides, setGuides] = useState<Guide[]>([])
|
||||||
|
// ref mirror of guides so callbacks don't need it in their deps
|
||||||
|
const guidesRef = useRef<Guide[]>([])
|
||||||
|
useEffect(() => { guidesRef.current = guides }, [guides])
|
||||||
|
// latest snap deltas, applied on drag stop
|
||||||
|
const pendingSnapRef = useRef<{ deltaX: number; deltaY: number; ids: Set<string> } | null>(null)
|
||||||
|
const altDownRef = useRef(false)
|
||||||
|
const { setNodes, getNodes } = useReactFlow<Node<NodeData>>()
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setSettings), [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => { if (e.altKey) altDownRef.current = true }
|
||||||
|
const up = (e: KeyboardEvent) => { if (!e.altKey) altDownRef.current = false }
|
||||||
|
window.addEventListener('keydown', down)
|
||||||
|
window.addEventListener('keyup', up)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', down)
|
||||||
|
window.removeEventListener('keyup', up)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const update = useCallback((patch: Partial<AlignmentSettings>) => {
|
||||||
|
setSettings((s) => {
|
||||||
|
const next = { ...s, ...patch }
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearState = useCallback(() => {
|
||||||
|
if (guidesRef.current.length > 0) setGuides([])
|
||||||
|
pendingSnapRef.current = null
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onNodeDrag: NodeDrag = useCallback((_event, dragNode, dragNodes) => {
|
||||||
|
if (!settings.enabled || altDownRef.current) {
|
||||||
|
clearState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const all = getNodes()
|
||||||
|
const draggedIds = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
|
||||||
|
// Restrict the snap to top-level dragged nodes. nodeBox returns null for
|
||||||
|
// parented nodes; if we kept their ids in pendingSnap, onNodeDragStop
|
||||||
|
// would shift their parent-relative position by the same delta the parent
|
||||||
|
// already moves by, double-snapping the child. Children follow the parent
|
||||||
|
// automatically; no extra shift needed.
|
||||||
|
const draggedBoxEntries = all
|
||||||
|
.filter((n) => draggedIds.has(n.id))
|
||||||
|
.map((n) => ({ id: n.id, box: nodeBox(n) }))
|
||||||
|
.filter((e): e is { id: string; box: Box } => e.box !== null)
|
||||||
|
if (draggedBoxEntries.length === 0) {
|
||||||
|
clearState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const snapIds = new Set(draggedBoxEntries.map((e) => e.id))
|
||||||
|
const boxes = draggedBoxEntries.map((e) => e.box)
|
||||||
|
const dragged = boxes.length === 1 ? boxes[0] : unionBox(boxes)
|
||||||
|
if (!dragged) return
|
||||||
|
const candidates = all
|
||||||
|
.filter((n) => !draggedIds.has(n.id))
|
||||||
|
.map(nodeBox)
|
||||||
|
.filter((b): b is Box => b !== null)
|
||||||
|
const result = computeSnap(dragged, candidates, settings.threshold)
|
||||||
|
setGuides(result.guides)
|
||||||
|
pendingSnapRef.current =
|
||||||
|
result.deltaX !== 0 || result.deltaY !== 0
|
||||||
|
? { deltaX: result.deltaX, deltaY: result.deltaY, ids: snapIds }
|
||||||
|
: null
|
||||||
|
}, [settings.enabled, settings.threshold, getNodes, clearState])
|
||||||
|
|
||||||
|
const onNodeDragStop: NodeDrag = useCallback(() => {
|
||||||
|
const pending = pendingSnapRef.current
|
||||||
|
if (pending) {
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
pending.ids.has(n.id)
|
||||||
|
? { ...n, position: { x: n.position.x + pending.deltaX, y: n.position.y + pending.deltaY } }
|
||||||
|
: n,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
clearState()
|
||||||
|
}, [setNodes, clearState])
|
||||||
|
|
||||||
|
return { guides, settings, update, onNodeDrag, onNodeDragStop }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -412,3 +412,24 @@ describe('round-trip: serialize → deserialize', () => {
|
|||||||
expect(restored.height).toBe(300)
|
expect(restored.height).toBe(300)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── text nodes survive save+reload (regression for #lost text node) ──────────
|
||||||
|
describe('serializeNode — text node roundtrip', () => {
|
||||||
|
const emptyMap = new Map<string, boolean>()
|
||||||
|
|
||||||
|
it('persists text content through label so it survives reload', () => {
|
||||||
|
const node = makeRfNode({
|
||||||
|
type: 'text',
|
||||||
|
data: {
|
||||||
|
label: 'Hello world',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const serialized = serializeNode(node) as ApiNode
|
||||||
|
expect(serialized.label).toBe('Hello world')
|
||||||
|
const restored = deserializeApiNode(serialized, emptyMap)
|
||||||
|
expect(restored.data.label).toBe('Hello world')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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