Merge pull request #5453 from xyflow/fix/nodedrag-snapgrid

chore(multiselect): snap selection instead of single nodes
This commit is contained in:
Moritz Klack
2025-08-13 20:58:14 +02:00
committed by GitHub
3 changed files with 71 additions and 28 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@xyflow/react': patch
'@xyflow/svelte': patch
'@xyflow/system': patch
---
Snap selection instead of separate nodes when snap grid is enabled
+25 -23
View File
@@ -10,7 +10,7 @@ import {
getInternalNodesBounds, getInternalNodesBounds,
rectToBox, rectToBox,
} from '../utils'; } from '../utils';
import { getDragItems, getEventHandlerParams, hasSelector } from './utils'; import { calculateSnapOffset, getDragItems, getEventHandlerParams, hasSelector } from './utils';
import type { import type {
NodeBase, NodeBase,
NodeDragItem, NodeDragItem,
@@ -25,7 +25,6 @@ import type {
PanBy, PanBy,
OnSelectionDrag, OnSelectionDrag,
UpdateNodePositions, UpdateNodePositions,
Box,
InternalNodeBase, InternalNodeBase,
} from '../types'; } from '../types';
@@ -132,39 +131,42 @@ export function XYDrag<OnNodeDrag extends (e: any, nodes: any, node: any) => voi
} = getStoreItems(); } = getStoreItems();
lastPos = { x, y }; lastPos = { x, y };
let hasChange = false; let hasChange = false;
let nodesBox: Box = { x: 0, y: 0, x2: 0, y2: 0 };
if (dragItems.size > 1 && nodeExtent) { const isMultiDrag = dragItems.size > 1;
const rect = getInternalNodesBounds(dragItems); const nodesBox = isMultiDrag && nodeExtent ? rectToBox(getInternalNodesBounds(dragItems)) : null;
nodesBox = rectToBox(rect); const multiDragSnapOffset =
} isMultiDrag && snapToGrid
? calculateSnapOffset({
dragItems,
snapGrid,
x,
y,
})
: null;
for (const [id, dragItem] of dragItems) { for (const [id, dragItem] of dragItems) {
/*
* if the node is not in the nodeLookup anymore, it was probably deleted while dragging
*/
if (!nodeLookup.has(id)) { if (!nodeLookup.has(id)) {
/*
* if the node is not in the nodeLookup anymore, it was probably deleted while dragging
* and we don't need to update it anymore
*/
continue; continue;
} }
let nextPosition = { x: x - dragItem.distance.x, y: y - dragItem.distance.y }; let nextPosition = { x: x - dragItem.distance.x, y: y - dragItem.distance.y };
if (snapToGrid) { if (snapToGrid) {
nextPosition = snapPosition(nextPosition, snapGrid); nextPosition = multiDragSnapOffset
? {
x: nextPosition.x + multiDragSnapOffset.x,
y: nextPosition.y + multiDragSnapOffset.y,
}
: snapPosition(nextPosition, snapGrid);
} }
/* let adjustedNodeExtent: CoordinateExtent | null = null;
* if there is selection with multiple nodes and a node extent is set, we need to adjust the node extent for each node
* based on its position so that the node stays at it's position relative to the selection.
*/
let adjustedNodeExtent: CoordinateExtent = [
[nodeExtent[0][0], nodeExtent[0][1]],
[nodeExtent[1][0], nodeExtent[1][1]],
];
if (dragItems.size > 1 && nodeExtent && !dragItem.extent) { if (isMultiDrag && nodeExtent && !dragItem.extent && nodesBox) {
const { positionAbsolute } = dragItem.internals; const { positionAbsolute } = dragItem.internals;
const x1 = positionAbsolute.x - nodesBox.x + nodeExtent[0][0]; const x1 = positionAbsolute.x - nodesBox.x + nodeExtent[0][0];
const x2 = positionAbsolute.x + dragItem.measured.width - nodesBox.x2 + nodeExtent[1][0]; const x2 = positionAbsolute.x + dragItem.measured.width - nodesBox.x2 + nodeExtent[1][0];
@@ -182,7 +184,7 @@ export function XYDrag<OnNodeDrag extends (e: any, nodes: any, node: any) => voi
nodeId: id, nodeId: id,
nextPosition, nextPosition,
nodeLookup, nodeLookup,
nodeExtent: adjustedNodeExtent, nodeExtent: adjustedNodeExtent ? adjustedNodeExtent : nodeExtent,
nodeOrigin, nodeOrigin,
onError, onError,
}); });
+39 -5
View File
@@ -1,4 +1,5 @@
import { type NodeDragItem, type XYPosition, InternalNodeBase, NodeBase, NodeLookup } from '../types'; import { type NodeDragItem, type XYPosition, InternalNodeBase, NodeBase, NodeLookup, SnapGrid } from '../types';
import { snapPosition } from '../utils';
export function isParentSelected<NodeType extends NodeBase>(node: NodeType, nodeLookup: NodeLookup): boolean { export function isParentSelected<NodeType extends NodeBase>(node: NodeType, nodeLookup: NodeLookup): boolean {
if (!node.parentId) { if (!node.parentId) {
@@ -114,10 +115,43 @@ export function getEventHandlerParams<NodeType extends InternalNodeBase>({
!node !node
? nodesFromDragItems[0] ? nodesFromDragItems[0]
: { : {
...node, ...node,
position: dragItems.get(nodeId)?.position || node.position, position: dragItems.get(nodeId)?.position || node.position,
dragging, dragging,
}, },
nodesFromDragItems, nodesFromDragItems,
]; ];
} }
/**
* If a selection is being dragged we want to apply the same snap offset to all nodes in the selection.
* This function calculates the snap offset based on the first node in the selection.
*/
export function calculateSnapOffset({
dragItems,
snapGrid,
x,
y,
}: {
dragItems: Map<string, NodeDragItem>;
snapGrid: SnapGrid;
x: number;
y: number;
}) {
const refDragItem = dragItems.values().next().value;
if (!refDragItem) {
return null;
}
const refPos = {
x: x - refDragItem.distance.x,
y: y - refDragItem.distance.y,
};
const refPosSnapped = snapPosition(refPos, snapGrid);
return {
x: refPosSnapped.x - refPos.x,
y: refPosSnapped.y - refPos.y,
};
}