fix(canvas): show alignment guides for nodes inside groups and containers

Alignment guides and snapping were skipped for any node with a parentId, so
dragging a node inside a group or a container_mode node (Proxmox, docker host)
gave no visual help at all.

Boxes are now expressed in absolute canvas coordinates by walking the parentId
chain, so a child aligns against its siblings, its parent group, and top-level
nodes outside the group. A node whose ancestor is also being dragged follows
that ancestor: it is excluded from the snap (its parent-relative position would
otherwise be shifted twice) and from the candidate set.

Fixes #328

ha-relevant: yes
This commit is contained in:
Pouzor
2026-08-13 00:58:58 +02:00
committed by Pouzor - Rémy Jardient
parent 79b682148e
commit e93aef3871
2 changed files with 211 additions and 15 deletions
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
import { useAlignmentGuides } from '../useAlignmentGuides'
const nodesRef: { current: Node<NodeData>[] } = { current: [] }
const setNodes = vi.fn()
vi.mock('@xyflow/react', async () => (await import('@/test/mocks')).mockReactFlow({
useReactFlow: () => ({
getNodes: () => nodesRef.current,
setNodes,
}),
}))
function makeFlowNode(
id: string,
x: number,
y: number,
over: Partial<Node<NodeData>> = {},
): Node<NodeData> {
return {
id,
type: 'custom',
position: { x, y },
data: { label: id, type: 'server' } as NodeData,
measured: { width: 100, height: 50 },
...over,
}
}
const evt = {} as MouseEvent
describe('useAlignmentGuides — parented nodes', () => {
beforeEach(() => {
localStorage.clear()
setNodes.mockClear()
nodesRef.current = []
})
it('emits guides for a node dragged inside a group (parent-relative coords)', () => {
// Group at (500, 500); two children in parent-relative coordinates.
// Child "b" sits 3px off "a" horizontally → within the default threshold.
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('a', 20, 20, { parentId: 'group' }),
makeFlowNode('b', 23, 200, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
})
// Guides are in absolute canvas space: group.x + a.x = 520.
const xGuides = result.current.guides.filter((g) => g.axis === 'x')
expect(xGuides.length).toBeGreaterThan(0)
expect(xGuides.map((g) => g.position)).toContain(520)
})
it('snaps the dragged child on drag stop, keeping its parent-relative position', () => {
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('a', 20, 20, { parentId: 'group' }),
makeFlowNode('b', 23, 200, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
result.current.onNodeDragStop(evt, nodesRef.current[2], [nodesRef.current[2]])
})
expect(setNodes).toHaveBeenCalledTimes(1)
const updated = setNodes.mock.calls[0][0](nodesRef.current) as Node<NodeData>[]
// -3 applied to the relative position: 23 → 20, absolute 523 → 520.
expect(updated.find((n) => n.id === 'b')!.position.x).toBe(20)
expect(updated.find((n) => n.id === 'a')!.position.x).toBe(20)
})
it('aligns a child against a top-level node outside its group', () => {
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('outside', 520, 50),
makeFlowNode('child', 24, 100, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
})
expect(result.current.guides.some((g) => g.axis === 'x' && g.position === 520)).toBe(true)
})
it('resolves nested container chains (container in a group)', () => {
nodesRef.current = [
makeFlowNode('group', 100, 100, { type: 'group', measured: { width: 600, height: 600 } }),
makeFlowNode('host', 50, 50, { parentId: 'group', measured: { width: 300, height: 300 } }),
makeFlowNode('vm1', 10, 10, { parentId: 'host' }),
makeFlowNode('vm2', 14, 120, { parentId: 'host' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[3], [nodesRef.current[3]])
})
// vm1 absolute x = 100 + 50 + 10 = 160.
expect(result.current.guides.some((g) => g.axis === 'x' && g.position === 160)).toBe(true)
})
it('does not snap a child whose parent is dragged too (no double shift)', () => {
nodesRef.current = [
makeFlowNode('group', 497, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('child', 23, 20, { parentId: 'group' }),
makeFlowNode('other', 500, 1200),
]
const { result } = renderHook(() => useAlignmentGuides())
const dragged = [nodesRef.current[0], nodesRef.current[1]]
act(() => {
result.current.onNodeDrag(evt, dragged[0], dragged)
result.current.onNodeDragStop(evt, dragged[0], dragged)
})
const updated = setNodes.mock.calls[0][0](nodesRef.current) as Node<NodeData>[]
// The group snaps by +3; the child follows it, so its relative x is untouched.
expect(updated.find((n) => n.id === 'group')!.position.x).toBe(500)
expect(updated.find((n) => n.id === 'child')!.position.x).toBe(23)
})
it('ignores nodes that follow the drag as alignment candidates', () => {
// Only the group is dragged; its child must not act as a candidate for itself.
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('child', 2, 20, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[0], [nodesRef.current[0]])
})
expect(result.current.guides).toHaveLength(0)
})
})
+63 -15
View File
@@ -11,14 +11,54 @@ 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
// Absolute canvas position of a node: React Flow stores a parented node's
// position relative to its parent, so we walk the parentId chain and sum.
// `seen` guards against a corrupted cycle in the hierarchy.
function absolutePosition(
n: Node<NodeData>,
byId: Map<string, Node<NodeData>>,
): { x: number; y: number } {
let x = n.position.x
let y = n.position.y
const seen = new Set<string>([n.id])
let parentId = n.parentId
while (parentId && !seen.has(parentId)) {
seen.add(parentId)
const parent = byId.get(parentId)
if (!parent) break
x += parent.position.x
y += parent.position.y
parentId = parent.parentId
}
return { x, y }
}
// Boxes are expressed in absolute canvas coordinates so a node inside a group
// or a container_mode node aligns against siblings *and* outside nodes.
function nodeBox(n: Node<NodeData>, byId: Map<string, Node<NodeData>>): Box | 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 }
const { x, y } = absolutePosition(n, byId)
return { id: n.id, x, y, width, height }
}
// True when any ancestor of `n` is itself being dragged — such a node follows
// its parent, so it must not be snapped again (double-shift) nor act as an
// alignment candidate (it moves with the drag).
function hasDraggedAncestor(
n: Node<NodeData>,
byId: Map<string, Node<NodeData>>,
draggedIds: Set<string>,
): boolean {
const seen = new Set<string>([n.id])
let parentId = n.parentId
while (parentId && !seen.has(parentId)) {
if (draggedIds.has(parentId)) return true
seen.add(parentId)
parentId = byId.get(parentId)?.parentId
}
return false
}
/**
@@ -74,27 +114,35 @@ export function useAlignmentGuides() {
return
}
const all = getNodes()
const byId = new Map(all.map((n) => [n.id, n]))
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.
// A dragged node whose parent is also dragged already moves with that
// parent; snapping it too would shift its parent-relative position by the
// same delta twice. Its box still joins the union bbox (it is part of what
// the user sees moving), it just never lands in pendingSnap.ids.
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)
.map((n) => ({
id: n.id,
box: nodeBox(n, byId),
follows: hasDraggedAncestor(n, byId, draggedIds),
}))
.filter((e): e is { id: string; box: Box; follows: boolean } => e.box !== null)
if (draggedBoxEntries.length === 0) {
clearState()
return
}
const snapIds = new Set(draggedBoxEntries.map((e) => e.id))
const snapIds = new Set(draggedBoxEntries.filter((e) => !e.follows).map((e) => e.id))
if (snapIds.size === 0) {
clearState()
return
}
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((n) => !draggedIds.has(n.id) && !hasDraggedAncestor(n, byId, draggedIds))
.map((n) => nodeBox(n, byId))
.filter((b): b is Box => b !== null)
const result = computeSnap(dragged, candidates, settings.threshold)
setGuides(result.guides)