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