From a50cecca9620aaafce81438966552c377ee786e2 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 13 Aug 2025 20:38:24 +0200 Subject: [PATCH 1/4] chore(multiselect): snap selection instead of single nodes #5389 --- packages/system/src/xydrag/XYDrag.ts | 48 +++++++++++++------------ packages/system/src/xydrag/utils.ts | 52 +++++++++++++++++++++++++--- 2 files changed, 72 insertions(+), 28 deletions(-) diff --git a/packages/system/src/xydrag/XYDrag.ts b/packages/system/src/xydrag/XYDrag.ts index ddd0d960..38b8c090 100644 --- a/packages/system/src/xydrag/XYDrag.ts +++ b/packages/system/src/xydrag/XYDrag.ts @@ -10,7 +10,7 @@ import { getInternalNodesBounds, rectToBox, } from '../utils'; -import { getDragItems, getEventHandlerParams, hasSelector } from './utils'; +import { calculateSnapOffset, getDragItems, getEventHandlerParams, hasSelector } from './utils'; import type { NodeBase, NodeDragItem, @@ -25,7 +25,6 @@ import type { PanBy, OnSelectionDrag, UpdateNodePositions, - Box, InternalNodeBase, } from '../types'; @@ -132,39 +131,42 @@ export function XYDrag voi } = getStoreItems(); lastPos = { x, y }; - let hasChange = false; - let nodesBox: Box = { x: 0, y: 0, x2: 0, y2: 0 }; - if (dragItems.size > 1 && nodeExtent) { - const rect = getInternalNodesBounds(dragItems); - nodesBox = rectToBox(rect); - } + const isMultiDrag = dragItems.size > 1; + const nodesBox = + isMultiDrag && nodeExtent ? rectToBox(getInternalNodesBounds(dragItems)) : { x: 0, y: 0, x2: 0, y2: 0 }; + const snapOffset = calculateSnapOffset({ + isMultiDrag, + dragItems, + snapToGrid, + snapGrid, + x, + y, + }); 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 the node is not in the nodeLookup anymore, it was probably deleted while dragging - * and we don't need to update it anymore - */ continue; } let nextPosition = { x: x - dragItem.distance.x, y: y - dragItem.distance.y }; + if (snapToGrid) { - nextPosition = snapPosition(nextPosition, snapGrid); + nextPosition = isMultiDrag + ? { + x: nextPosition.x + snapOffset.x, + y: nextPosition.y + snapOffset.y, + } + : snapPosition(nextPosition, snapGrid); } - /* - * 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]], - ]; + let adjustedNodeExtent: CoordinateExtent | null = null; - if (dragItems.size > 1 && nodeExtent && !dragItem.extent) { + if (isMultiDrag && nodeExtent && !dragItem.extent) { const { positionAbsolute } = dragItem.internals; const x1 = positionAbsolute.x - nodesBox.x + nodeExtent[0][0]; const x2 = positionAbsolute.x + dragItem.measured.width - nodesBox.x2 + nodeExtent[1][0]; @@ -182,7 +184,7 @@ export function XYDrag voi nodeId: id, nextPosition, nodeLookup, - nodeExtent: adjustedNodeExtent, + nodeExtent: adjustedNodeExtent ? adjustedNodeExtent : nodeExtent, nodeOrigin, onError, }); diff --git a/packages/system/src/xydrag/utils.ts b/packages/system/src/xydrag/utils.ts index dc980281..61733dbc 100644 --- a/packages/system/src/xydrag/utils.ts +++ b/packages/system/src/xydrag/utils.ts @@ -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(node: NodeType, nodeLookup: NodeLookup): boolean { if (!node.parentId) { @@ -114,10 +115,51 @@ export function getEventHandlerParams({ !node ? nodesFromDragItems[0] : { - ...node, - position: dragItems.get(nodeId)?.position || node.position, - dragging, - }, + ...node, + position: dragItems.get(nodeId)?.position || node.position, + dragging, + }, 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({ + isMultiDrag, + dragItems, + snapToGrid, + snapGrid, + x, + y, +}: { + isMultiDrag: boolean; + dragItems: Map; + snapToGrid: boolean; + snapGrid: SnapGrid; + x: number; + y: number; +}) { + let snapOffset: XYPosition = { x: 0, y: 0 }; + + if (snapToGrid && isMultiDrag) { + const refDragItem = dragItems.values().next().value; + + if (refDragItem) { + const refPos = { + x: x - refDragItem.distance.x, + y: y - refDragItem.distance.y, + }; + const refPosSnapped = snapPosition(refPos, snapGrid); + + snapOffset = { + x: refPosSnapped.x - refPos.x, + y: refPosSnapped.y - refPos.y, + }; + } + } + + return snapOffset; +} From 7a088817f71acb71c49e5bf4ac90352dab95f7b8 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 13 Aug 2025 20:39:29 +0200 Subject: [PATCH 2/4] chore(changeset): add --- .changeset/perfect-poems-tap.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/perfect-poems-tap.md diff --git a/.changeset/perfect-poems-tap.md b/.changeset/perfect-poems-tap.md new file mode 100644 index 00000000..0c416943 --- /dev/null +++ b/.changeset/perfect-poems-tap.md @@ -0,0 +1,5 @@ +--- +'@xyflow/system': patch +--- + +Snap selection instead of separate nodes when snap grid is enabled From 6b2d1009a297e7b507754d25296ddbb40a73a9f7 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 13 Aug 2025 20:56:19 +0200 Subject: [PATCH 3/4] chore(xydrag): cleanup --- packages/system/src/xydrag/XYDrag.ts | 28 +++++++++++------------ packages/system/src/xydrag/utils.ts | 34 +++++++++++----------------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/packages/system/src/xydrag/XYDrag.ts b/packages/system/src/xydrag/XYDrag.ts index 38b8c090..232fd574 100644 --- a/packages/system/src/xydrag/XYDrag.ts +++ b/packages/system/src/xydrag/XYDrag.ts @@ -134,16 +134,16 @@ export function XYDrag voi let hasChange = false; const isMultiDrag = dragItems.size > 1; - const nodesBox = - isMultiDrag && nodeExtent ? rectToBox(getInternalNodesBounds(dragItems)) : { x: 0, y: 0, x2: 0, y2: 0 }; - const snapOffset = calculateSnapOffset({ - isMultiDrag, - dragItems, - snapToGrid, - snapGrid, - x, - y, - }); + const nodesBox = isMultiDrag && nodeExtent ? rectToBox(getInternalNodesBounds(dragItems)) : null; + const multiDragSnapOffset = + isMultiDrag && snapToGrid + ? calculateSnapOffset({ + dragItems, + snapGrid, + x, + y, + }) + : null; for (const [id, dragItem] of dragItems) { /* @@ -156,17 +156,17 @@ export function XYDrag voi let nextPosition = { x: x - dragItem.distance.x, y: y - dragItem.distance.y }; if (snapToGrid) { - nextPosition = isMultiDrag + nextPosition = multiDragSnapOffset ? { - x: nextPosition.x + snapOffset.x, - y: nextPosition.y + snapOffset.y, + x: nextPosition.x + multiDragSnapOffset.x, + y: nextPosition.y + multiDragSnapOffset.y, } : snapPosition(nextPosition, snapGrid); } let adjustedNodeExtent: CoordinateExtent | null = null; - if (isMultiDrag && nodeExtent && !dragItem.extent) { + if (isMultiDrag && nodeExtent && !dragItem.extent && nodesBox) { const { positionAbsolute } = dragItem.internals; const x1 = positionAbsolute.x - nodesBox.x + nodeExtent[0][0]; const x2 = positionAbsolute.x + dragItem.measured.width - nodesBox.x2 + nodeExtent[1][0]; diff --git a/packages/system/src/xydrag/utils.ts b/packages/system/src/xydrag/utils.ts index 61733dbc..a62d7735 100644 --- a/packages/system/src/xydrag/utils.ts +++ b/packages/system/src/xydrag/utils.ts @@ -128,38 +128,30 @@ export function getEventHandlerParams({ * This function calculates the snap offset based on the first node in the selection. */ export function calculateSnapOffset({ - isMultiDrag, dragItems, - snapToGrid, snapGrid, x, y, }: { - isMultiDrag: boolean; dragItems: Map; - snapToGrid: boolean; snapGrid: SnapGrid; x: number; y: number; }) { - let snapOffset: XYPosition = { x: 0, y: 0 }; + const refDragItem = dragItems.values().next().value; - if (snapToGrid && isMultiDrag) { - const refDragItem = dragItems.values().next().value; - - if (refDragItem) { - const refPos = { - x: x - refDragItem.distance.x, - y: y - refDragItem.distance.y, - }; - const refPosSnapped = snapPosition(refPos, snapGrid); - - snapOffset = { - x: refPosSnapped.x - refPos.x, - y: refPosSnapped.y - refPos.y, - }; - } + if (!refDragItem) { + return null; } - return snapOffset; + 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, + }; } From 3c3da0e2d5ed86d800a385128b30c517d9c51c77 Mon Sep 17 00:00:00 2001 From: Moritz Klack Date: Wed, 13 Aug 2025 20:58:04 +0200 Subject: [PATCH 4/4] Update perfect-poems-tap.md --- .changeset/perfect-poems-tap.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/perfect-poems-tap.md b/.changeset/perfect-poems-tap.md index 0c416943..698201c6 100644 --- a/.changeset/perfect-poems-tap.md +++ b/.changeset/perfect-poems-tap.md @@ -1,4 +1,6 @@ --- +'@xyflow/react': patch +'@xyflow/svelte': patch '@xyflow/system': patch ---