fix(alignment): only snap top-level dragged nodes

Code review flagged silent corruption when dragging a mixed selection
of a parent + one of its children: the child's ids stayed in
pendingSnap even though nodeBox excluded it from the bbox. On drag
stop we shifted the child's parent-relative position by the same delta
the parent already moves by — double-snapping the child off-screen.

Restrict the pendingSnap id set to nodes that contributed a box
(top-level only). Children follow their parent's move automatically;
no extra shift is needed.
This commit is contained in:
Pouzor
2026-05-11 01:23:37 +02:00
parent 5e9963db4d
commit 062a08f116
+16 -6
View File
@@ -74,23 +74,33 @@ export function useAlignmentGuides() {
return
}
const all = getNodes()
const ids = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
const draggedBoxes = all.filter((n) => ids.has(n.id)).map(nodeBox).filter((b): b is Box => b !== null)
if (draggedBoxes.length === 0) {
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 dragged = draggedBoxes.length === 1 ? draggedBoxes[0] : unionBox(draggedBoxes)
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) => !ids.has(n.id))
.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 }
? { deltaX: result.deltaX, deltaY: result.deltaY, ids: snapIds }
: null
}, [settings.enabled, settings.threshold, getNodes, clearState])