diff --git a/.changeset/eighty-crabs-shake.md b/.changeset/eighty-crabs-shake.md
new file mode 100644
index 0000000000..7e7a86c13c
--- /dev/null
+++ b/.changeset/eighty-crabs-shake.md
@@ -0,0 +1,7 @@
+---
+'@xyflow/react': minor
+'@xyflow/svelte': minor
+'@xyflow/system': patch
+---
+
+Add `autoPanOnSelection` to auto-pan when user drags a selection close to the edge of the viewport.
diff --git a/examples/react/src/examples/Basic/index.tsx b/examples/react/src/examples/Basic/index.tsx
index a24c908c89..ac86be9735 100644
--- a/examples/react/src/examples/Basic/index.tsx
+++ b/examples/react/src/examples/Basic/index.tsx
@@ -162,6 +162,7 @@ const BasicFlow = () => {
selectNodesOnDrag={false}
elevateEdgesOnSelect
elevateNodesOnSelect={false}
+ autoPanOnSelection={true}
nodeDragThreshold={0}
>
diff --git a/examples/svelte/src/routes/examples/overview/Flow.svelte b/examples/svelte/src/routes/examples/overview/Flow.svelte
index e83548fdd3..129cb90481 100644
--- a/examples/svelte/src/routes/examples/overview/Flow.svelte
+++ b/examples/svelte/src/routes/examples/overview/Flow.svelte
@@ -216,6 +216,7 @@
snapGrid={[25, 25]}
autoPanOnConnect
autoPanOnNodeDrag
+ autoPanOnSelection={true}
connectionMode={ConnectionMode.Strict}
attributionPosition={'top-center'}
deleteKey={['Backspace', 'd']}
diff --git a/packages/react/src/container/FlowRenderer/index.tsx b/packages/react/src/container/FlowRenderer/index.tsx
index 2198ce5eb2..93e764a923 100644
--- a/packages/react/src/container/FlowRenderer/index.tsx
+++ b/packages/react/src/container/FlowRenderer/index.tsx
@@ -61,6 +61,7 @@ function FlowRendererComponent({
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag: _panOnDrag,
+ autoPanOnSelection,
defaultViewport,
translateExtent,
minZoom,
@@ -118,6 +119,7 @@ function FlowRendererComponent({
onPaneContextMenu={onPaneContextMenu}
onPaneScroll={onPaneScroll}
panOnDrag={panOnDrag}
+ autoPanOnSelection={autoPanOnSelection}
isSelecting={!!isSelecting}
selectionMode={selectionMode}
selectionKeyPressed={selectionKeyPressed}
diff --git a/packages/react/src/container/GraphView/index.tsx b/packages/react/src/container/GraphView/index.tsx
index fd0296f170..3a43661f15 100644
--- a/packages/react/src/container/GraphView/index.tsx
+++ b/packages/react/src/container/GraphView/index.tsx
@@ -80,6 +80,7 @@ function GraphViewComponent ({
elementsSelectable: s.elementsSelectable,
connectionInProgress: s.connection.inProgress,
dragging: s.paneDragging,
+ panBy: s.panBy,
+ autoPanSpeed: s.autoPanSpeed,
});
export function Pane({
@@ -63,6 +76,7 @@ export function Pane({
selectionKeyPressed,
selectionMode = SelectionMode.Full,
panOnDrag,
+ autoPanOnSelection,
paneClickDistance,
selectionOnDrag,
onSelectionStart,
@@ -75,8 +89,12 @@ export function Pane({
onPaneMouseLeave,
children,
}: PaneProps) {
+ const autoPanId = useRef(0);
const store = useStoreApi();
- const { userSelectionActive, elementsSelectable, dragging, connectionInProgress } = useStore(selector, shallow);
+ const { userSelectionActive, elementsSelectable, dragging, connectionInProgress, panBy, autoPanSpeed } = useStore(
+ selector,
+ shallow
+ );
const isSelectionEnabled = elementsSelectable && (isSelecting || userSelectionActive);
const container = useRef(null);
@@ -87,6 +105,10 @@ export function Pane({
// Used to prevent click events when the user lets go of the selectionKey during a selection
const selectionInProgress = useRef(false);
+ // Used for auto pan when approaching the edges of the container during selection
+ const position = useRef({ x: 0, y: 0 });
+ const autoPanStarted = useRef(false);
+
const onClick = (event: ReactMouseEvent) => {
// We prevent click events when the user let go of the selectionKey during a selection
// We also prevent click events when a connection is in progress
@@ -121,7 +143,7 @@ export function Pane({
// We are using capture here in order to prevent other pointer events
// to be able to create a selection above a node or an edge
const onPointerDownCapture = (event: ReactPointerEvent): void => {
- const { domNode } = store.getState();
+ const { domNode, transform } = store.getState();
containerBounds.current = domNode?.getBoundingClientRect();
if (!containerBounds.current) return;
@@ -139,13 +161,14 @@ export function Pane({
selectionInProgress.current = false;
const { x, y } = getEventPosition(event.nativeEvent, containerBounds.current);
+ const userSelectionStartPosition = pointToRendererPoint({ x, y }, transform);
store.setState({
userSelectionRect: {
width: 0,
height: 0,
- startX: x,
- startY: y,
+ startX: userSelectionStartPosition.x,
+ startY: userSelectionStartPosition.y,
x,
y,
},
@@ -157,9 +180,14 @@ export function Pane({
}
};
- const onPointerMove = (event: ReactPointerEvent): void => {
+ // We commit the user selection rectangle to the store on auto-panning or pointer move during selection.
+ function commitUserSelectionRect(mouseX: number, mouseY: number): void {
+ const { userSelectionRect } = store.getState();
+ if (!userSelectionRect) {
+ return;
+ }
+
const {
- userSelectionRect,
transform,
nodeLookup,
edgeLookup,
@@ -167,35 +195,20 @@ export function Pane({
triggerNodeChanges,
triggerEdgeChanges,
defaultEdgeOptions,
- resetSelectedElements,
} = store.getState();
- if (!containerBounds.current || !userSelectionRect) {
- return;
- }
-
- const { x: mouseX, y: mouseY } = getEventPosition(event.nativeEvent, containerBounds.current);
- const { startX, startY } = userSelectionRect;
-
- if (!selectionInProgress.current) {
- const requiredDistance = selectionKeyPressed ? 0 : paneClickDistance;
- const distance = Math.hypot(mouseX - startX, mouseY - startY);
- if (distance <= requiredDistance) {
- return;
- }
- resetSelectedElements();
- onSelectionStart?.(event);
- }
-
- selectionInProgress.current = true;
-
+ const userStartPosition = { x: userSelectionRect.startX, y: userSelectionRect.startY };
+ const { x: screenStartX, y: screenStartY } = rendererPointToPoint(userStartPosition, transform);
+ // This has to be in screen coordinates, not in flow coordinates.
+ // We store the selection rectangle in userSelectionStartPosition coordinates to be able to
+ // fix the start position of the selection rectangle when we are auto-panning.
const nextUserSelectRect = {
- startX,
- startY,
- x: mouseX < startX ? mouseX : startX,
- y: mouseY < startY ? mouseY : startY,
- width: Math.abs(mouseX - startX),
- height: Math.abs(mouseY - startY),
+ startX: userStartPosition.x,
+ startY: userStartPosition.y,
+ x: mouseX < screenStartX ? mouseX : screenStartX,
+ y: mouseY < screenStartY ? mouseY : screenStartY,
+ width: Math.abs(mouseX - screenStartX),
+ height: Math.abs(mouseY - screenStartY),
};
const prevSelectedNodeIds = selectedNodeIds.current;
@@ -237,6 +250,65 @@ export function Pane({
userSelectionActive: true,
nodesSelectionActive: false,
});
+ }
+
+ function autoPan(): void {
+ if (!autoPanOnSelection || !containerBounds.current) {
+ return;
+ }
+ const [x, y] = calcAutoPan(position.current, containerBounds.current, autoPanSpeed);
+
+ panBy({ x, y }).then((panned) => {
+ if (!selectionInProgress.current || !panned) {
+ autoPanId.current = requestAnimationFrame(autoPan);
+ return;
+ }
+ const { x: mx, y: my } = position.current;
+ commitUserSelectionRect(mx, my);
+ autoPanId.current = requestAnimationFrame(autoPan);
+ });
+ }
+
+ const cleanupAutoPan = (): void => {
+ cancelAnimationFrame(autoPanId.current);
+ autoPanId.current = 0;
+ autoPanStarted.current = false;
+ };
+
+ useEffect(() => {
+ return () => cleanupAutoPan();
+ }, []);
+
+ const onPointerMove = (event: ReactPointerEvent): void => {
+ const { userSelectionRect, transform, resetSelectedElements } = store.getState();
+
+ if (!containerBounds.current || !userSelectionRect) {
+ return;
+ }
+
+ const { x: mouseX, y: mouseY } = getEventPosition(event.nativeEvent, containerBounds.current);
+ position.current = { x: mouseX, y: mouseY };
+
+ const screenStart = rendererPointToPoint({ x: userSelectionRect.startX, y: userSelectionRect.startY }, transform);
+
+ if (!selectionInProgress.current) {
+ const requiredDistance = selectionKeyPressed ? 0 : paneClickDistance;
+ const distance = Math.hypot(mouseX - screenStart.x, mouseY - screenStart.y);
+ if (distance <= requiredDistance) {
+ return;
+ }
+ resetSelectedElements();
+ onSelectionStart?.(event);
+ }
+
+ selectionInProgress.current = true;
+
+ if (!autoPanStarted.current) {
+ autoPan();
+ autoPanStarted.current = true;
+ }
+
+ commitUserSelectionRect(mouseX, mouseY);
};
const onPointerUp = (event: ReactPointerEvent) => {
@@ -266,6 +338,13 @@ export function Pane({
nodesSelectionActive: selectedNodeIds.current.size > 0,
});
}
+
+ cleanupAutoPan();
+ };
+
+ const onPointerCancel = (event: ReactPointerEvent) => {
+ (event.target as Partial)?.releasePointerCapture?.(event.pointerId);
+ cleanupAutoPan();
};
const draggable = panOnDrag === true || (Array.isArray(panOnDrag) && panOnDrag.includes(0));
@@ -279,6 +358,7 @@ export function Pane({
onPointerEnter={isSelectionEnabled ? undefined : onPaneMouseEnter}
onPointerMove={isSelectionEnabled ? onPointerMove : onPaneMouseMove}
onPointerUp={isSelectionEnabled ? onPointerUp : undefined}
+ onPointerCancel={isSelectionEnabled ? onPointerCancel : undefined}
onPointerDownCapture={isSelectionEnabled ? onPointerDownCapture : undefined}
onClickCapture={isSelectionEnabled ? onClickCapture : undefined}
onPointerLeave={onPaneMouseLeave}
diff --git a/packages/react/src/container/ReactFlow/index.tsx b/packages/react/src/container/ReactFlow/index.tsx
index 1bbe506c2b..32ff50e8ca 100644
--- a/packages/react/src/container/ReactFlow/index.tsx
+++ b/packages/react/src/container/ReactFlow/index.tsx
@@ -132,6 +132,7 @@ function ReactFlow(
disableKeyboardA11y = false,
autoPanOnConnect,
autoPanOnNodeDrag,
+ autoPanOnSelection = true,
autoPanSpeed,
connectionRadius,
isValidConnection,
@@ -285,6 +286,7 @@ function ReactFlow(
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
+ autoPanOnSelection={autoPanOnSelection}
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
diff --git a/packages/react/src/types/component-props.ts b/packages/react/src/types/component-props.ts
index effc7ca66c..4ff095b24f 100644
--- a/packages/react/src/types/component-props.ts
+++ b/packages/react/src/types/component-props.ts
@@ -634,6 +634,12 @@ export interface ReactFlowProps