From 062a08f1165df4368f5c47fc41d8368b5d43f70f Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 11 May 2026 01:23:37 +0200 Subject: [PATCH] fix(alignment): only snap top-level dragged nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/hooks/useAlignmentGuides.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/src/hooks/useAlignmentGuides.ts b/frontend/src/hooks/useAlignmentGuides.ts index 751f123..d60ed09 100644 --- a/frontend/src/hooks/useAlignmentGuides.ts +++ b/frontend/src/hooks/useAlignmentGuides.ts @@ -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])