Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/eighty-crabs-shake.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions examples/react/src/examples/Basic/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const BasicFlow = () => {
selectNodesOnDrag={false}
elevateEdgesOnSelect
elevateNodesOnSelect={false}
autoPanOnSelection={true}
nodeDragThreshold={0}
>
<Background variant={BackgroundVariant.Dots} />
Expand Down
1 change: 1 addition & 0 deletions examples/svelte/src/routes/examples/overview/Flow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@
snapGrid={[25, 25]}
autoPanOnConnect
autoPanOnNodeDrag
autoPanOnSelection={true}
connectionMode={ConnectionMode.Strict}
attributionPosition={'top-center'}
deleteKey={['Backspace', 'd']}
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/container/FlowRenderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function FlowRendererComponent<NodeType extends Node = Node>({
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag: _panOnDrag,
autoPanOnSelection,
defaultViewport,
translateExtent,
minZoom,
Expand Down Expand Up @@ -118,6 +119,7 @@ function FlowRendererComponent<NodeType extends Node = Node>({
onPaneContextMenu={onPaneContextMenu}
onPaneScroll={onPaneScroll}
panOnDrag={panOnDrag}
autoPanOnSelection={autoPanOnSelection}
isSelecting={!!isSelecting}
selectionMode={selectionMode}
selectionKeyPressed={selectionKeyPressed}
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/container/GraphView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ function GraphViewComponent<NodeType extends Node = Node, EdgeType extends Edge
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
autoPanOnSelection,
onPaneClick,
onPaneMouseEnter,
onPaneMouseMove,
Expand Down Expand Up @@ -138,6 +139,7 @@ function GraphViewComponent<NodeType extends Node = Node, EdgeType extends Edge
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
autoPanOnSelection={autoPanOnSelection}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
Expand Down
146 changes: 113 additions & 33 deletions packages/react/src/container/Pane/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
useEffect,
useRef,
type MouseEventHandler,
type MutableRefObject,
Expand All @@ -9,7 +10,16 @@ import {
} from 'react';
import { shallow } from 'zustand/shallow';
import cc from 'classcat';
import { getNodesInside, getEventPosition, SelectionMode, areSetsEqual } from '@xyflow/system';
import {
getNodesInside,
getEventPosition,
SelectionMode,
areSetsEqual,
calcAutoPan,
pointToRendererPoint,
rendererPointToPoint,
XYPosition,
} from '@xyflow/system';

import { UserSelection } from '../../components/UserSelection';
import { containerStyle } from '../../styles/utils';
Expand All @@ -27,6 +37,7 @@ type PaneProps = {
ReactFlowProps,
| 'selectionMode'
| 'panOnDrag'
| 'autoPanOnSelection'
| 'onSelectionStart'
| 'onSelectionEnd'
| 'onPaneClick'
Expand Down Expand Up @@ -56,13 +67,16 @@ const selector = (s: ReactFlowState) => ({
elementsSelectable: s.elementsSelectable,
connectionInProgress: s.connection.inProgress,
dragging: s.paneDragging,
panBy: s.panBy,
autoPanSpeed: s.autoPanSpeed,
});

export function Pane({
isSelecting,
selectionKeyPressed,
selectionMode = SelectionMode.Full,
panOnDrag,
autoPanOnSelection,
paneClickDistance,
selectionOnDrag,
onSelectionStart,
Expand All @@ -75,8 +89,12 @@ export function Pane({
onPaneMouseLeave,
children,
}: PaneProps) {
const autoPanId = useRef<number>(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<HTMLDivElement | null>(null);
Expand All @@ -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<boolean>(false);

// Used for auto pan when approaching the edges of the container during selection
const position = useRef<XYPosition>({ x: 0, y: 0 });
const autoPanStarted = useRef<boolean>(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
Expand Down Expand Up @@ -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;

Expand All @@ -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,
},
Expand All @@ -157,45 +180,35 @@ 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,
connectionLookup,
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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -266,6 +338,13 @@ export function Pane({
nodesSelectionActive: selectedNodeIds.current.size > 0,
});
}

cleanupAutoPan();
};

const onPointerCancel = (event: ReactPointerEvent) => {
(event.target as Partial<Element>)?.releasePointerCapture?.(event.pointerId);
cleanupAutoPan();
};

const draggable = panOnDrag === true || (Array.isArray(panOnDrag) && panOnDrag.includes(0));
Expand All @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions packages/react/src/container/ReactFlow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
disableKeyboardA11y = false,
autoPanOnConnect,
autoPanOnNodeDrag,
autoPanOnSelection = true,
autoPanSpeed,
connectionRadius,
isValidConnection,
Expand Down Expand Up @@ -285,6 +286,7 @@ function ReactFlow<NodeType extends Node = Node, EdgeType extends Edge = Edge>(
panOnScrollSpeed={panOnScrollSpeed}
panOnScrollMode={panOnScrollMode}
panOnDrag={panOnDrag}
autoPanOnSelection={autoPanOnSelection}
onPaneClick={onPaneClick}
onPaneMouseEnter={onPaneMouseEnter}
onPaneMouseMove={onPaneMouseMove}
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/types/component-props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,12 @@ export interface ReactFlowProps<NodeType extends Node = Node, EdgeType extends E
* @default true
*/
autoPanOnConnect?: boolean;
/**
* When `true`, the viewport will pan automatically when the cursor moves to the edge of the
* viewport while creating a selection box.
* @default true
*/
autoPanOnSelection?: boolean;
/**
* The speed at which the viewport pans while dragging a node or a selection box.
* @default 15
Expand Down
Loading
Loading