feat(utils): add vanilla drag and panzoom (#3108)

* refactor(panzoom): create vanilla helper

* feat(svelte): add interaction example

* refactor(panzoom): cleanup

* Update function-runner.ts

* refactor(panzoom): cleanup

* refactor(panzoom): rename functions

* feat(utils): add vanilla drag helper (#3107)

* feat(utils): add vanilla drag helper

* refactor(drag): cleanup

* refactor(drag): cleanup

* chore(packages): cleanup

* refactor(panzoom): cleanup and simplify
This commit is contained in:
Moritz Klack
2023-05-31 15:55:36 +02:00
committed by GitHub
parent d8ef041737
commit 1dab08f36e
59 changed files with 3258 additions and 4313 deletions
@@ -40,7 +40,6 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
isSelectable,
isConnectable,
isFocusable,
selectNodesOnDrag,
sourcePosition,
targetPosition,
hidden,
@@ -69,6 +68,7 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
const onContextMenuHandler = getMouseHandler(id, store.getState, onContextMenu);
const onDoubleClickHandler = getMouseHandler(id, store.getState, onDoubleClick);
const onSelectNodeHandler = (event: MouseEvent) => {
const { selectNodesOnDrag } = store.getState();
if (isSelectable && (!selectNodesOnDrag || !isDraggable)) {
// this handler gets called within the drag start event when selectNodesOnDrag=true
handleNodeClick({
@@ -154,7 +154,6 @@ export default (NodeComponent: ComponentType<NodeProps>) => {
handleSelector: dragHandle,
nodeId: id,
isSelectable,
selectNodesOnDrag,
});
if (hidden) {
@@ -44,6 +44,9 @@ type StoreUpdaterProps = Pick<
| 'onSelectionDragStart'
| 'onSelectionDrag'
| 'onSelectionDragStop'
| 'onMove'
| 'onMoveStart'
| 'onMoveEnd'
| 'noPanClassName'
| 'nodeOrigin'
| 'elevateNodesOnSelect'
@@ -52,6 +55,7 @@ type StoreUpdaterProps = Pick<
| 'onError'
| 'connectionRadius'
| 'isValidConnection'
| 'selectNodesOnDrag'
> & { rfId: string };
const selector = (s: ReactFlowState) => ({
@@ -124,6 +128,9 @@ const StoreUpdater = ({
onSelectionDrag,
onSelectionDragStart,
onSelectionDragStop,
onMoveStart,
onMove,
onMoveEnd,
noPanClassName,
nodeOrigin,
rfId,
@@ -132,6 +139,7 @@ const StoreUpdater = ({
onError,
connectionRadius,
isValidConnection,
selectNodesOnDrag,
}: StoreUpdaterProps) => {
const {
setNodes,
@@ -183,6 +191,9 @@ const StoreUpdater = ({
useDirectStoreUpdater('onSelectionDrag', onSelectionDrag, store.setState);
useDirectStoreUpdater('onSelectionDragStart', onSelectionDragStart, store.setState);
useDirectStoreUpdater('onSelectionDragStop', onSelectionDragStop, store.setState);
useDirectStoreUpdater('onMove', onMove, store.setState);
useDirectStoreUpdater('onMoveStart', onMoveStart, store.setState);
useDirectStoreUpdater('onMoveEnd', onMoveEnd, store.setState);
useDirectStoreUpdater('noPanClassName', noPanClassName, store.setState);
useDirectStoreUpdater('nodeOrigin', nodeOrigin, store.setState);
useDirectStoreUpdater('rfId', rfId, store.setState);
@@ -191,6 +202,7 @@ const StoreUpdater = ({
useDirectStoreUpdater('onError', onError, store.setState);
useDirectStoreUpdater('connectionRadius', connectionRadius, store.setState);
useDirectStoreUpdater('isValidConnection', isValidConnection, store.setState);
useDirectStoreUpdater('selectNodesOnDrag', selectNodesOnDrag, store.setState);
useStoreUpdater<Node[]>(nodes, setNodes);
useStoreUpdater<Edge[]>(edges, setEdges);
@@ -38,9 +38,6 @@ const FlowRenderer = ({
onPaneContextMenu,
onPaneScroll,
deleteKeyCode,
onMove,
onMoveStart,
onMoveEnd,
selectionKeyCode,
selectionOnDrag,
selectionMode,
@@ -78,9 +75,6 @@ const FlowRenderer = ({
return (
<ZoomPane
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
onPaneContextMenu={onPaneContextMenu}
elementsSelectable={elementsSelectable}
zoomOnScroll={zoomOnScroll}
@@ -8,7 +8,10 @@ import useOnInitHandler from '../../hooks/useOnInitHandler';
import ConnectionLine from '../../components/ConnectionLine';
import type { EdgeTypesWrapped, NodeTypesWrapped, ReactFlowProps } from '../../types';
export type GraphViewProps = Omit<ReactFlowProps, 'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes'> &
export type GraphViewProps = Omit<
ReactFlowProps,
'onSelectionChange' | 'nodes' | 'edges' | 'nodeTypes' | 'edgeTypes' | 'onMove' | 'onMoveStart' | 'onMoveEnd'
> &
Required<
Pick<
ReactFlowProps,
@@ -21,7 +24,6 @@ export type GraphViewProps = Omit<ReactFlowProps, 'onSelectionChange' | 'nodes'
| 'minZoom'
| 'maxZoom'
| 'defaultMarkerColor'
| 'selectNodesOnDrag'
| 'noDragClassName'
| 'noDragClassName'
| 'noWheelClassName'
@@ -39,9 +41,6 @@ export type GraphViewProps = Omit<ReactFlowProps, 'onSelectionChange' | 'nodes'
const GraphView = ({
nodeTypes,
edgeTypes,
onMove,
onMoveStart,
onMoveEnd,
onInit,
onNodeClick,
onEdgeClick,
@@ -67,7 +66,6 @@ const GraphView = ({
deleteKeyCode,
onlyRenderVisibleElements,
elementsSelectable,
selectNodesOnDrag,
defaultViewport,
translateExtent,
minZoom,
@@ -124,9 +122,6 @@ const GraphView = ({
panActivationKeyCode={panActivationKeyCode}
zoomActivationKeyCode={zoomActivationKeyCode}
elementsSelectable={elementsSelectable}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
zoomOnDoubleClick={zoomOnDoubleClick}
@@ -182,7 +177,6 @@ const GraphView = ({
onNodeMouseMove={onNodeMouseMove}
onNodeMouseLeave={onNodeMouseLeave}
onNodeContextMenu={onNodeContextMenu}
selectNodesOnDrag={selectNodesOnDrag}
onlyRenderVisibleElements={onlyRenderVisibleElements}
noPanClassName={noPanClassName}
noDragClassName={noDragClassName}
@@ -12,7 +12,6 @@ import type { ReactFlowState, WrapNodeProps } from '../../types';
type NodeRendererProps = Pick<
GraphViewProps,
| 'nodeTypes'
| 'selectNodesOnDrag'
| 'onNodeClick'
| 'onNodeDoubleClick'
| 'onNodeMouseEnter'
@@ -115,7 +114,6 @@ const NodeRenderer = (props: NodeRendererProps) => {
yPos={posY}
xPosOrigin={posOrigin.x}
yPosOrigin={posOrigin.y}
selectNodesOnDrag={props.selectNodesOnDrag}
onClick={props.onNodeClick}
onMouseEnter={props.onNodeMouseEnter}
onMouseMove={props.onNodeMouseMove}
@@ -120,7 +120,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
nodeOrigin = initNodeOrigin,
edgesFocusable,
edgesUpdatable,
elementsSelectable,
elementsSelectable = true,
defaultViewport = initDefaultViewport,
minZoom = 0.5,
maxZoom = 2,
@@ -192,9 +192,6 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
<Wrapper>
<GraphView
onInit={onInit}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onNodeMouseEnter={onNodeMouseEnter}
@@ -216,7 +213,6 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
panActivationKeyCode={panActivationKeyCode}
zoomActivationKeyCode={zoomActivationKeyCode}
onlyRenderVisibleElements={onlyRenderVisibleElements}
selectNodesOnDrag={selectNodesOnDrag}
defaultViewport={defaultViewport}
translateExtent={translateExtent}
minZoom={minZoom}
@@ -295,6 +291,9 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onSelectionDrag={onSelectionDrag}
onSelectionDragStart={onSelectionDragStart}
onSelectionDragStop={onSelectionDragStop}
onMove={onMove}
onMoveStart={onMoveStart}
onMoveEnd={onMoveEnd}
noPanClassName={noPanClassName}
nodeOrigin={nodeOrigin}
rfId={rfId}
@@ -303,6 +302,7 @@ const ReactFlow = forwardRef<ReactFlowRefType, ReactFlowProps>(
onError={onError}
connectionRadius={connectionRadius}
isValidConnection={isValidConnection}
selectNodesOnDrag={selectNodesOnDrag}
/>
<SelectionListener onSelectionChange={onSelectionChange} />
{children}
+56 -129
View File
@@ -1,17 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect, useRef } from 'react';
import { shallow } from 'zustand/shallow';
import {
initPanZoom,
createPanOnScrollHandler,
createZoomOnScrollHandler,
createFilter,
createPanZoomStartHandler,
ZoomPanValues,
createPanZoomHandler,
createPanZoomEndHandler,
} from '@reactflow/utils';
import { PanOnScrollMode, Transform } from '@reactflow/system';
import { XYPanZoom } from '@reactflow/utils';
import { PanOnScrollMode, type Transform, type PanZoomInstance } from '@reactflow/system';
import useKeyPress from '../../hooks/useKeyPress';
import useResizeHandler from '../../hooks/useResizeHandler';
@@ -31,16 +22,13 @@ type ZoomPaneProps = Omit<
>;
const selector = (s: ReactFlowState) => ({
d3Zoom: s.d3Zoom,
d3Selection: s.d3Selection,
d3ZoomHandler: s.d3ZoomHandler,
userSelectionActive: s.userSelectionActive,
onViewportChangeStart: s.onViewportChangeStart,
onViewportChange: s.onViewportChange,
onViewportChangeEnd: s.onViewportChangeEnd,
});
const ZoomPane = ({
onMove,
onMoveStart,
onMoveEnd,
onPaneContextMenu,
zoomOnScroll = true,
zoomOnPinch = true,
@@ -48,7 +36,6 @@ const ZoomPane = ({
panOnScrollSpeed = 0.5,
panOnScrollMode = PanOnScrollMode.Free,
zoomOnDoubleClick = true,
elementsSelectable,
panOnDrag = true,
defaultViewport,
translateExtent,
@@ -62,143 +49,83 @@ const ZoomPane = ({
}: ZoomPaneProps) => {
const store = useStoreApi();
const zoomPane = useRef<HTMLDivElement>(null);
const { d3Zoom, d3Selection, d3ZoomHandler, userSelectionActive } = useStore(selector, shallow);
const { userSelectionActive } = useStore(selector, shallow);
const zoomActivationKeyPressed = useKeyPress(zoomActivationKeyCode);
const zoomPanValues = useRef<ZoomPanValues>({
isZoomingOrPanning: false,
zoomedWithRightMouseButton: false,
prevTransform: { x: 0, y: 0, zoom: 0 },
mouseButton: 0,
timerId: undefined,
});
const panZoom = useRef<PanZoomInstance>();
useResizeHandler(zoomPane);
useEffect(() => {
if (zoomPane.current) {
const { d3ZoomInstance, d3Selection, d3ZoomHandler, transform } = initPanZoom({
panZoom.current = XYPanZoom({
domNode: zoomPane.current,
minZoom,
maxZoom,
translateExtent,
defaultViewport,
viewport: defaultViewport,
onTransformChange: (transform: Transform) => store.setState({ transform }),
onDraggingChange: (paneDragging: boolean) => store.setState({ paneDragging }),
onPanZoomStart: (event, vp) => {
const { onViewportChangeStart, onMoveStart } = store.getState();
onMoveStart?.(event, vp);
onViewportChangeStart?.(vp);
},
onPanZoom: (event, vp) => {
const { onViewportChange, onMove } = store.getState();
onMove?.(event, vp);
onViewportChange?.(vp);
},
onPanZoomEnd: (event, vp) => {
const { onViewportChangeEnd, onMoveEnd } = store.getState();
onMoveEnd?.(event, vp);
onViewportChangeEnd?.(vp);
},
});
const { x, y, zoom } = panZoom.current.getViewport();
store.setState({
d3Zoom: d3ZoomInstance,
d3Selection,
d3ZoomHandler,
transform,
panZoom: panZoom.current,
transform: [x, y, zoom],
domNode: zoomPane.current.closest('.react-flow') as HTMLDivElement,
});
return () => {
panZoom.current?.destroy();
};
}
}, []);
useEffect(() => {
if (d3Selection && d3Zoom) {
if (panOnScroll && !zoomActivationKeyPressed && !userSelectionActive) {
const panOnScrollHandler = createPanOnScrollHandler({
noWheelClassName,
d3Selection,
d3Zoom,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
});
d3Selection.on('wheel.zoom', panOnScrollHandler, { passive: false });
} else if (d3ZoomHandler !== null) {
const zoomOnScrollHandler = createZoomOnScrollHandler({
noWheelClassName,
preventScrolling,
d3ZoomHandler,
});
d3Selection.on('wheel.zoom', zoomOnScrollHandler, { passive: false });
}
}
panZoom.current?.update({
onPaneContextMenu,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
zoomActivationKeyPressed,
preventScrolling,
noPanClassName,
userSelectionActive,
noWheelClassName,
});
}, [
userSelectionActive,
panOnScroll,
panOnScrollMode,
d3Selection,
d3Zoom,
d3ZoomHandler,
zoomActivationKeyPressed,
zoomOnPinch,
preventScrolling,
noWheelClassName,
]);
useEffect(() => {
if (d3Zoom) {
const startHandler = createPanZoomStartHandler({
zoomPanValues: zoomPanValues.current,
setDragging: (paneDragging: boolean) => store.setState({ paneDragging }),
onMoveStart,
onViewportChangeStart: store.getState().onViewportChangeStart,
});
d3Zoom.on('start', startHandler);
}
}, [d3Zoom, onMoveStart]);
useEffect(() => {
if (d3Zoom) {
if (userSelectionActive && !zoomPanValues.current.isZoomingOrPanning) {
d3Zoom.on('zoom', null);
} else if (!userSelectionActive) {
const panZoomHandler = createPanZoomHandler({
zoomPanValues: zoomPanValues.current,
panOnDrag,
onPaneContextMenu: !!onPaneContextMenu,
onMove,
onViewportChange: store.getState().onViewportChange,
setTransform: (transform: Transform) => store.setState({ transform }),
});
d3Zoom.on('zoom', panZoomHandler);
}
}
}, [userSelectionActive, d3Zoom, onMove, panOnDrag, onPaneContextMenu]);
useEffect(() => {
if (d3Zoom) {
const panZoomEndHandler = createPanZoomEndHandler({
zoomPanValues: zoomPanValues.current,
panOnDrag,
panOnScroll,
onPaneContextMenu,
onMoveEnd,
onViewportChangeEnd: store.getState().onViewportChangeEnd,
setDragging: (paneDragging: boolean) => store.setState({ paneDragging }),
});
d3Zoom.on('end', panZoomEndHandler);
}
}, [d3Zoom, panOnScroll, panOnDrag, onMoveEnd, onPaneContextMenu]);
useEffect(() => {
if (d3Zoom) {
const filter = createFilter({
zoomActivationKeyPressed,
panOnDrag,
zoomOnScroll,
panOnScroll,
zoomOnDoubleClick,
zoomOnPinch,
userSelectionActive,
noPanClassName,
noWheelClassName,
});
d3Zoom.filter(filter);
}
}, [
userSelectionActive,
d3Zoom,
onPaneContextMenu,
zoomOnScroll,
zoomOnPinch,
panOnScroll,
panOnScrollSpeed,
panOnScrollMode,
zoomOnDoubleClick,
panOnDrag,
elementsSelectable,
zoomActivationKeyPressed,
preventScrolling,
noPanClassName,
userSelectionActive,
noWheelClassName,
]);
return (
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { XYDrag, type XYDragInstance } from '@reactflow/utils';
import { handleNodeClick } from '../components/Nodes/utils';
import { useStoreApi } from './useStore';
type UseDragParams = {
nodeRef: RefObject<Element>;
disabled?: boolean;
noDragClassName?: string;
handleSelector?: string;
nodeId?: string;
isSelectable?: boolean;
};
function useDrag({ nodeRef, disabled = false, noDragClassName, handleSelector, nodeId, isSelectable }: UseDragParams) {
const store = useStoreApi();
const [dragging, setDragging] = useState<boolean>(false);
const xyDrag = useRef<XYDragInstance>();
useEffect(() => {
if (nodeRef?.current) {
xyDrag.current = XYDrag({
domNode: nodeRef.current,
getStoreItems: () => {
const currentStore = store.getState();
return {
nodes: currentStore.getNodes(),
...store.getState(),
};
},
onNodeClick: () => {
if (nodeId) {
handleNodeClick({
id: nodeId,
store,
nodeRef: nodeRef as RefObject<HTMLDivElement>,
});
}
},
onDragStart: () => {
setDragging(true);
},
onDragStop: () => {
setDragging(false);
},
});
}
}, []);
useEffect(() => {
if (disabled) {
xyDrag.current?.destroy();
} else {
xyDrag.current?.update({
noDragClassName,
handleSelector,
domNode: nodeRef.current as Element,
isSelectable,
nodeId,
});
return () => {
xyDrag.current?.destroy();
};
}
}, [noDragClassName, handleSelector, disabled, isSelectable, nodeRef, nodeId]);
return dragging;
}
export default useDrag;
-250
View File
@@ -1,250 +0,0 @@
import { useEffect, useRef, useState, type RefObject, type MouseEvent } from 'react';
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import { calcAutoPan, getEventPosition } from '@reactflow/utils';
import type { NodeDragItem, UseDragEvent, XYPosition } from '@reactflow/system';
import { useStoreApi } from '../../hooks/useStore';
import { getDragItems, getEventHandlerParams, hasSelector, calcNextPosition } from './utils';
import { handleNodeClick } from '../../components/Nodes/utils';
import useGetPointerPosition from '../useGetPointerPosition';
import type { Node, SelectionDragHandler } from '../../types';
export type UseDragData = { dx: number; dy: number };
type UseDragParams = {
nodeRef: RefObject<Element>;
disabled?: boolean;
noDragClassName?: string;
handleSelector?: string;
nodeId?: string;
isSelectable?: boolean;
selectNodesOnDrag?: boolean;
};
function wrapSelectionDragFunc(selectionFunc?: SelectionDragHandler) {
return (event: MouseEvent, _: Node, nodes: Node[]) => selectionFunc?.(event, nodes);
}
function useDrag({
nodeRef,
disabled = false,
noDragClassName,
handleSelector,
nodeId,
isSelectable,
selectNodesOnDrag,
}: UseDragParams) {
const store = useStoreApi();
const [dragging, setDragging] = useState<boolean>(false);
const dragItems = useRef<NodeDragItem[]>([]);
const lastPos = useRef<{ x: number | null; y: number | null }>({ x: null, y: null });
const autoPanId = useRef(0);
const containerBounds = useRef<DOMRect | null>(null);
const mousePosition = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const dragEvent = useRef<MouseEvent | null>(null);
const autoPanStarted = useRef(false);
const getPointerPosition = useGetPointerPosition();
useEffect(() => {
if (nodeRef?.current) {
const selection = select(nodeRef.current);
const updateNodes = ({ x, y }: XYPosition) => {
const {
nodeInternals,
onNodeDrag,
onSelectionDrag,
updateNodePositions,
nodeExtent,
snapGrid,
snapToGrid,
nodeOrigin,
onError,
} = store.getState();
lastPos.current = { x, y };
let hasChange = false;
dragItems.current = dragItems.current.map((n) => {
const nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
if (snapToGrid) {
nextPosition.x = snapGrid[0] * Math.round(nextPosition.x / snapGrid[0]);
nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
}
const updatedPos = calcNextPosition(n, nextPosition, nodeInternals, nodeExtent, nodeOrigin, onError);
// we want to make sure that we only fire a change event when there is a changes
hasChange = hasChange || n.position.x !== updatedPos.position.x || n.position.y !== updatedPos.position.y;
n.position = updatedPos.position;
n.positionAbsolute = updatedPos.positionAbsolute;
return n;
});
if (!hasChange) {
return;
}
updateNodePositions(dragItems.current, true, true);
setDragging(true);
const onDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag);
if (onDrag && dragEvent.current) {
const [currentNode, nodes] = getEventHandlerParams({
nodeId,
dragItems: dragItems.current,
nodeInternals,
});
onDrag(dragEvent.current as MouseEvent, currentNode, nodes);
}
};
const autoPan = (): void => {
if (!containerBounds.current) {
return;
}
const [xMovement, yMovement] = calcAutoPan(mousePosition.current, containerBounds.current);
if (xMovement !== 0 || yMovement !== 0) {
const { transform, panBy } = store.getState();
lastPos.current.x = (lastPos.current.x ?? 0) - xMovement / transform[2];
lastPos.current.y = (lastPos.current.y ?? 0) - yMovement / transform[2];
if (panBy({ x: xMovement, y: yMovement })) {
updateNodes(lastPos.current as XYPosition);
}
}
autoPanId.current = requestAnimationFrame(autoPan);
};
if (disabled) {
selection.on('.drag', null);
} else {
const dragHandler = drag()
.on('start', (event: UseDragEvent) => {
const {
nodeInternals,
multiSelectionActive,
domNode,
nodesDraggable,
unselectNodesAndEdges,
onNodeDragStart,
onSelectionDragStart,
} = store.getState();
const onStart = nodeId ? onNodeDragStart : wrapSelectionDragFunc(onSelectionDragStart);
if (!selectNodesOnDrag && !multiSelectionActive && nodeId) {
if (!nodeInternals.get(nodeId)?.selected) {
// we need to reset selected nodes when selectNodesOnDrag=false
unselectNodesAndEdges();
}
}
if (nodeId && isSelectable && selectNodesOnDrag) {
handleNodeClick({
id: nodeId,
store,
nodeRef: nodeRef as RefObject<HTMLDivElement>,
});
}
const pointerPos = getPointerPosition(event);
lastPos.current = pointerPos;
dragItems.current = getDragItems(nodeInternals, nodesDraggable, pointerPos, nodeId);
if (onStart && dragItems.current) {
const [currentNode, nodes] = getEventHandlerParams({
nodeId,
dragItems: dragItems.current,
nodeInternals,
});
onStart(event.sourceEvent as MouseEvent, currentNode, nodes);
}
containerBounds.current = domNode?.getBoundingClientRect() || null;
mousePosition.current = getEventPosition(event.sourceEvent, containerBounds.current!);
})
.on('drag', (event: UseDragEvent) => {
const pointerPos = getPointerPosition(event);
const { autoPanOnNodeDrag } = store.getState();
if (!autoPanStarted.current && autoPanOnNodeDrag) {
autoPanStarted.current = true;
autoPan();
}
// skip events without movement
if (
(lastPos.current.x !== pointerPos.xSnapped || lastPos.current.y !== pointerPos.ySnapped) &&
dragItems.current
) {
dragEvent.current = event.sourceEvent as MouseEvent;
mousePosition.current = getEventPosition(event.sourceEvent, containerBounds.current!);
updateNodes(pointerPos);
}
})
.on('end', (event: UseDragEvent) => {
setDragging(false);
autoPanStarted.current = false;
cancelAnimationFrame(autoPanId.current);
if (dragItems.current) {
const { updateNodePositions, nodeInternals, onNodeDragStop, onSelectionDragStop } = store.getState();
const onStop = nodeId ? onNodeDragStop : wrapSelectionDragFunc(onSelectionDragStop);
updateNodePositions(dragItems.current, false, false);
if (onStop) {
const [currentNode, nodes] = getEventHandlerParams({
nodeId,
dragItems: dragItems.current,
nodeInternals,
});
onStop(event.sourceEvent as MouseEvent, currentNode, nodes);
}
}
})
.filter((event: MouseEvent) => {
const target = event.target as HTMLDivElement;
const isDraggable =
!event.button &&
(!noDragClassName || !hasSelector(target, `.${noDragClassName}`, nodeRef)) &&
(!handleSelector || hasSelector(target, handleSelector, nodeRef));
return isDraggable;
});
selection.call(dragHandler);
return () => {
selection.on('.drag', null);
};
}
}
}, [
nodeRef,
disabled,
noDragClassName,
handleSelector,
isSelectable,
store,
nodeId,
selectNodesOnDrag,
getPointerPosition,
]);
return dragging;
}
export default useDrag;
-162
View File
@@ -1,162 +0,0 @@
import type { RefObject } from 'react';
import {
errorMessages,
type CoordinateExtent,
type NodeDragItem,
type NodeOrigin,
type OnError,
type XYPosition,
} from '@reactflow/system';
import { clampPosition, isNumeric, getNodePositionWithOrigin } from '@reactflow/utils';
import type { Node, NodeInternals } from '../../types';
export function isParentSelected(node: Node, nodeInternals: NodeInternals): boolean {
if (!node.parentNode) {
return false;
}
const parentNode = nodeInternals.get(node.parentNode);
if (!parentNode) {
return false;
}
if (parentNode.selected) {
return true;
}
return isParentSelected(parentNode, nodeInternals);
}
export function hasSelector(target: Element, selector: string, nodeRef: RefObject<Element>): boolean {
let current = target;
do {
if (current?.matches(selector)) return true;
if (current === nodeRef.current) return false;
current = current.parentElement as Element;
} while (current);
return false;
}
// looks for all selected nodes and created a NodeDragItem for each of them
export function getDragItems(
nodeInternals: NodeInternals,
nodesDraggable: boolean,
mousePos: XYPosition,
nodeId?: string
): NodeDragItem[] {
return Array.from(nodeInternals.values())
.filter(
(n) =>
(n.selected || n.id === nodeId) &&
(!n.parentNode || !isParentSelected(n, nodeInternals)) &&
(n.draggable || (nodesDraggable && typeof n.draggable === 'undefined'))
)
.map((n) => ({
id: n.id,
position: n.position || { x: 0, y: 0 },
positionAbsolute: n.positionAbsolute || { x: 0, y: 0 },
distance: {
x: mousePos.x - (n.positionAbsolute?.x ?? 0),
y: mousePos.y - (n.positionAbsolute?.y ?? 0),
},
delta: {
x: 0,
y: 0,
},
extent: n.extent,
parentNode: n.parentNode,
width: n.width,
height: n.height,
origin: n.origin,
}));
}
export function calcNextPosition(
node: NodeDragItem | Node,
nextPosition: XYPosition,
nodeInternals: NodeInternals,
nodeExtent?: CoordinateExtent,
nodeOrigin: NodeOrigin = [0, 0],
onError?: OnError
): { position: XYPosition; positionAbsolute: XYPosition } {
let currentExtent = node.extent || nodeExtent;
if (node.extent === 'parent') {
if (node.parentNode && node.width && node.height) {
const parent = nodeInternals.get(node.parentNode);
const parentOrigin = parent?.origin || nodeOrigin;
const currNodeOrigin = node.origin || nodeOrigin;
const { x: parentX, y: parentY } = getNodePositionWithOrigin(parent, parentOrigin).positionAbsolute;
currentExtent =
parent && isNumeric(parentX) && isNumeric(parentY) && isNumeric(parent.width) && isNumeric(parent.height)
? [
[parentX + node.width * currNodeOrigin[0], parentY + node.height * currNodeOrigin[1]],
[
parentX + parent.width - node.width + node.width * currNodeOrigin[0],
parentY + parent.height - node.height + node.height * currNodeOrigin[1],
],
]
: currentExtent;
} else {
onError?.('005', errorMessages['error005']());
currentExtent = nodeExtent;
}
} else if (node.extent && node.parentNode) {
const parent = nodeInternals.get(node.parentNode);
const { x: parentX, y: parentY } = getNodePositionWithOrigin(parent, parent?.origin || nodeOrigin).positionAbsolute;
currentExtent = [
[node.extent[0][0] + parentX, node.extent[0][1] + parentY],
[node.extent[1][0] + parentX, node.extent[1][1] + parentY],
];
}
let parentPosition = { x: 0, y: 0 };
if (node.parentNode) {
const parentNode = nodeInternals.get(node.parentNode);
parentPosition = getNodePositionWithOrigin(parentNode, parentNode?.origin || nodeOrigin).positionAbsolute;
}
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return {
position: {
x: positionAbsolute.x - parentPosition.x,
y: positionAbsolute.y - parentPosition.y,
},
positionAbsolute,
};
}
// returns two params:
// 1. the dragged node (or the first of the list, if we are dragging a node selection)
// 2. array of selected nodes (for multi selections)
export function getEventHandlerParams({
nodeId,
dragItems,
nodeInternals,
}: {
nodeId?: string;
dragItems: NodeDragItem[];
nodeInternals: NodeInternals;
}): [Node, Node[]] {
const extentedDragItems: Node[] = dragItems.map((n) => {
const node = nodeInternals.get(n.id)!;
return {
...node,
position: n.position,
positionAbsolute: n.positionAbsolute,
};
});
return [nodeId ? extentedDragItems.find((n) => n.id === nodeId)! : extentedDragItems[0], extentedDragItems];
}
@@ -1,31 +0,0 @@
import { useCallback } from 'react';
import type { UseDragEvent } from '@reactflow/system';
import { useStoreApi } from './useStore';
function useGetPointerPosition() {
const store = useStoreApi();
// returns the pointer position projected to the RF coordinate system
const getPointerPosition = useCallback(({ sourceEvent }: UseDragEvent) => {
const { transform, snapGrid, snapToGrid } = store.getState();
const x = sourceEvent.touches ? sourceEvent.touches[0].clientX : sourceEvent.clientX;
const y = sourceEvent.touches ? sourceEvent.touches[0].clientY : sourceEvent.clientY;
const pointerPos = {
x: (x - transform[0]) / transform[2],
y: (y - transform[1]) / transform[2],
};
// we need the snapped position in order to be able to skip unnecessary drag events
return {
xSnapped: snapToGrid ? snapGrid[0] * Math.round(pointerPos.x / snapGrid[0]) : pointerPos.x,
ySnapped: snapToGrid ? snapGrid[1] * Math.round(pointerPos.y / snapGrid[1]) : pointerPos.y,
...pointerPos,
};
}, []);
return getPointerPosition;
}
export default useGetPointerPosition;
@@ -1,15 +1,16 @@
import { useCallback } from 'react';
import { calcNextPosition } from '@reactflow/utils';
import { useStoreApi } from '../hooks/useStore';
import { calcNextPosition } from './useDrag/utils';
function useUpdateNodePositions() {
const store = useStoreApi();
const updatePositions = useCallback((params: { x: number; y: number; isShiftPressed: boolean }) => {
const { nodeInternals, nodeExtent, updateNodePositions, getNodes, snapToGrid, snapGrid, onError, nodesDraggable } =
const { nodeExtent, updateNodePositions, getNodes, snapToGrid, snapGrid, onError, nodesDraggable } =
store.getState();
const selectedNodes = getNodes().filter(
const nodes = getNodes();
const selectedNodes = nodes.filter(
(n) => n.selected && (n.draggable || (nodesDraggable && typeof n.draggable === 'undefined'))
);
// by default a node moves 5px on each key press, or 20px if shift is pressed
@@ -30,14 +31,7 @@ function useUpdateNodePositions() {
nextPosition.y = snapGrid[1] * Math.round(nextPosition.y / snapGrid[1]);
}
const { positionAbsolute, position } = calcNextPosition(
n,
nextPosition,
nodeInternals,
nodeExtent,
undefined,
onError
);
const { positionAbsolute, position } = calcNextPosition(n, nextPosition, nodes, nodeExtent, undefined, onError);
n.position = position;
n.positionAbsolute = positionAbsolute;
+76 -86
View File
@@ -1,104 +1,94 @@
import { useMemo } from 'react';
import { zoomIdentity } from 'd3-zoom';
import { shallow } from 'zustand/shallow';
import { pointToRendererPoint, getTransformForBounds, getD3Transition, fitView } from '@reactflow/utils';
import { pointToRendererPoint, getTransformForBounds, fitView } from '@reactflow/utils';
import type { XYPosition } from '@reactflow/system';
import { useStoreApi, useStore } from '../hooks/useStore';
import type { ViewportHelperFunctions, ReactFlowState } from '../types';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noop = () => {};
const initialViewportHelper: ViewportHelperFunctions = {
zoomIn: noop,
zoomOut: noop,
zoomTo: noop,
getZoom: () => 1,
setViewport: noop,
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
fitView: () => false,
setCenter: noop,
fitBounds: noop,
project: (position: XYPosition) => position,
viewportInitialized: false,
};
const selector = (s: ReactFlowState) => ({
d3Zoom: s.d3Zoom,
d3Selection: s.d3Selection,
});
const selector = (s: ReactFlowState) => !!s.panZoom;
const useViewportHelper = (): ViewportHelperFunctions => {
const store = useStoreApi();
const { d3Zoom, d3Selection } = useStore(selector, shallow);
const panZoomInitialized = useStore(selector);
const viewportHelperFunctions = useMemo<ViewportHelperFunctions>(() => {
if (d3Selection && d3Zoom) {
return {
zoomIn: (options) => d3Zoom.scaleBy(getD3Transition(d3Selection, options?.duration), 1.2),
zoomOut: (options) => d3Zoom.scaleBy(getD3Transition(d3Selection, options?.duration), 1 / 1.2),
zoomTo: (zoomLevel, options) => d3Zoom.scaleTo(getD3Transition(d3Selection, options?.duration), zoomLevel),
getZoom: () => store.getState().transform[2],
setViewport: (transform, options) => {
const [x, y, zoom] = store.getState().transform;
const nextTransform = zoomIdentity
.translate(transform.x ?? x, transform.y ?? y)
.scale(transform.zoom ?? zoom);
d3Zoom.transform(getD3Transition(d3Selection, options?.duration), nextTransform);
},
getViewport: () => {
const [x, y, zoom] = store.getState().transform;
return { x, y, zoom };
},
fitView: (options) => {
const { getNodes, width, height, nodeOrigin, minZoom, maxZoom, d3Selection, d3Zoom } = store.getState();
const d3Initialized = d3Selection && d3Zoom;
return {
zoomIn: (options) => store.getState().panZoom?.scaleBy(1.2, { duration: options?.duration }),
zoomOut: (options) => store.getState().panZoom?.scaleBy(1 / 1.2, { duration: options?.duration }),
zoomTo: (zoomLevel, options) => store.getState().panZoom?.scaleTo(zoomLevel, { duration: options?.duration }),
getZoom: () => store.getState().transform[2],
setViewport: (viewport, options) => {
const {
transform: [tX, tY, tZoom],
panZoom,
} = store.getState();
if (!d3Initialized) {
return false;
}
panZoom?.setViewport(
{
x: viewport.x ?? tX,
y: viewport.y ?? tY,
zoom: viewport.zoom ?? tZoom,
},
{ duration: options?.duration }
);
},
getViewport: () => {
const [x, y, zoom] = store.getState().transform;
return { x, y, zoom };
},
fitView: (options) => {
const { getNodes, width, height, nodeOrigin, minZoom, maxZoom, panZoom } = store.getState();
return fitView(
{
nodes: getNodes(),
width,
height,
nodeOrigin,
minZoom,
maxZoom,
d3Selection,
d3Zoom,
},
options
);
},
setCenter: (x, y, options) => {
const { width, height, maxZoom } = store.getState();
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom;
const centerX = width / 2 - x * nextZoom;
const centerY = height / 2 - y * nextZoom;
const transform = zoomIdentity.translate(centerX, centerY).scale(nextZoom);
return panZoom
? fitView(
{
nodes: getNodes(),
width,
height,
nodeOrigin,
minZoom,
maxZoom,
panZoom,
},
options
)
: false;
},
setCenter: (x, y, options) => {
const { width, height, maxZoom, panZoom } = store.getState();
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : maxZoom;
const centerX = width / 2 - x * nextZoom;
const centerY = height / 2 - y * nextZoom;
d3Zoom.transform(getD3Transition(d3Selection, options?.duration), transform);
},
fitBounds: (bounds, options) => {
const { width, height, minZoom, maxZoom } = store.getState();
const [x, y, zoom] = getTransformForBounds(bounds, width, height, minZoom, maxZoom, options?.padding ?? 0.1);
const transform = zoomIdentity.translate(x, y).scale(zoom);
panZoom?.setViewport(
{
x: centerX,
y: centerY,
zoom: nextZoom,
},
{ duration: options?.duration }
);
},
fitBounds: (bounds, options) => {
const { width, height, minZoom, maxZoom, panZoom } = store.getState();
const [x, y, zoom] = getTransformForBounds(bounds, width, height, minZoom, maxZoom, options?.padding ?? 0.1);
d3Zoom.transform(getD3Transition(d3Selection, options?.duration), transform);
},
project: (position: XYPosition) => {
const { transform, snapToGrid, snapGrid } = store.getState();
return pointToRendererPoint(position, transform, snapToGrid, snapGrid);
},
viewportInitialized: true,
};
}
return initialViewportHelper;
}, [d3Zoom, d3Selection]);
panZoom?.setViewport(
{
x,
y,
zoom,
},
{ duration: options?.duration }
);
},
project: (position: XYPosition) => {
const { transform, snapToGrid, snapGrid } = store.getState();
return pointToRendererPoint(position, transform, snapToGrid, snapGrid);
},
viewportInitialized: panZoomInitialized,
};
}, [panZoomInitialized]);
return viewportHelperFunctions;
};
-1
View File
@@ -22,7 +22,6 @@ export { useStore, useStoreApi } from './hooks/useStore';
export { default as useOnViewportChange, type UseOnViewportChangeOptions } from './hooks/useOnViewportChange';
export { default as useOnSelectionChange, type UseOnSelectionChangeOptions } from './hooks/useOnSelectionChange';
export { default as useNodesInitialized, type UseNodesInitializedOptions } from './hooks/useNodesInitialized';
export { default as useGetPointerPosition } from './hooks/useGetPointerPosition';
export { useNodeId } from './contexts/NodeIdContext';
export * from '@reactflow/edge-utils';
export * from '@reactflow/system';
+19 -21
View File
@@ -1,5 +1,4 @@
import { createStore } from 'zustand';
import { zoomIdentity } from 'd3-zoom';
import { clampPosition, getDimensions, fitView, getHandleBounds } from '@reactflow/utils';
import {
internalsSymbol,
@@ -62,8 +61,7 @@ const createRFStore = () =>
height,
minZoom,
maxZoom,
d3Selection,
d3Zoom,
panZoom,
} = get();
const viewportNode = domNode?.querySelector('.react-flow__viewport');
@@ -115,15 +113,13 @@ const createRFStore = () =>
fitViewOnInitDone ||
(fitViewOnInit &&
!fitViewOnInitDone &&
!!d3Zoom &&
!!d3Selection &&
!!panZoom &&
fitView(
{
nodes: Array.from(nodeInternals.values()),
width,
height,
d3Zoom,
d3Selection,
panZoom,
minZoom,
maxZoom,
nodeOrigin,
@@ -230,19 +226,19 @@ const createRFStore = () =>
});
},
setMinZoom: (minZoom: number) => {
const { d3Zoom, maxZoom } = get();
d3Zoom?.scaleExtent([minZoom, maxZoom]);
const { panZoom, maxZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ minZoom });
},
setMaxZoom: (maxZoom: number) => {
const { d3Zoom, minZoom } = get();
d3Zoom?.scaleExtent([minZoom, maxZoom]);
const { panZoom, minZoom } = get();
panZoom?.setScaleExtent([minZoom, maxZoom]);
set({ maxZoom });
},
setTranslateExtent: (translateExtent: CoordinateExtent) => {
get().d3Zoom?.translateExtent(translateExtent);
get().panZoom?.setTranslateExtent(translateExtent);
set({ translateExtent });
},
@@ -277,26 +273,28 @@ const createRFStore = () =>
});
},
panBy: (delta: XYPosition): boolean => {
const { transform, width, height, d3Zoom, d3Selection, translateExtent } = get();
const { transform, width, height, panZoom, translateExtent } = get();
if (!d3Zoom || !d3Selection || (!delta.x && !delta.y)) {
if (!panZoom || (!delta.x && !delta.y)) {
return false;
}
const nextTransform = zoomIdentity.translate(transform[0] + delta.x, transform[1] + delta.y).scale(transform[2]);
const extent: CoordinateExtent = [
[0, 0],
[width, height],
];
const constrainedTransform = d3Zoom?.constrain()(nextTransform, extent, translateExtent);
d3Zoom.transform(d3Selection, constrainedTransform);
const constrainedTransform = panZoom.setViewportConstrained(
{ x: transform[0] + delta.x, y: transform[1] + delta.y, zoom: transform[2] },
extent,
translateExtent
);
const transformChanged =
transform[0] !== constrainedTransform.x ||
transform[1] !== constrainedTransform.y ||
transform[2] !== constrainedTransform.k;
!!constrainedTransform &&
(transform[0] !== constrainedTransform.x ||
transform[1] !== constrainedTransform.y ||
transform[2] !== constrainedTransform.k);
return transformChanged;
},
+2 -3
View File
@@ -14,9 +14,7 @@ const initialState: ReactFlowStore = {
onEdgesChange: null,
hasDefaultNodes: false,
hasDefaultEdges: false,
d3Zoom: null,
d3Selection: null,
d3ZoomHandler: null,
panZoom: null,
minZoom: 0.5,
maxZoom: 2,
translateExtent: infiniteExtent,
@@ -48,6 +46,7 @@ const initialState: ReactFlowStore = {
fitViewOnInit: false,
fitViewOnInitDone: false,
fitViewOnInitOptions: undefined,
selectNodesOnDrag: true,
multiSelectionActive: false,
+1 -1
View File
@@ -88,7 +88,7 @@ export type ReactFlowProps = HTMLAttributes<HTMLDivElement> & {
onSelectionChange?: OnSelectionChangeFunc;
onPaneScroll?: (event?: WheelEvent) => void;
onPaneClick?: (event: ReactMouseEvent) => void;
onPaneContextMenu?: (event: ReactMouseEvent) => void;
onPaneContextMenu?: (event: ReactMouseEvent | MouseEvent) => void;
onPaneMouseEnter?: (event: ReactMouseEvent) => void;
onPaneMouseMove?: (event: ReactMouseEvent) => void;
onPaneMouseLeave?: (event: ReactMouseEvent) => void;
-1
View File
@@ -28,7 +28,6 @@ export type WrapNodeProps<NodeData = any> = Pick<
isSelectable: boolean;
isDraggable: boolean;
isFocusable: boolean;
selectNodesOnDrag: boolean;
onClick?: NodeMouseHandler;
onDoubleClick?: NodeMouseHandler;
onMouseEnter?: NodeMouseHandler;
+35 -30
View File
@@ -1,26 +1,29 @@
import {
ConnectionMode,
ConnectionStatus,
CoordinateExtent,
D3SelectionInstance,
D3ZoomInstance,
HandleType,
NodeDimensionUpdate,
NodeDragItem,
NodeOrigin,
OnConnect,
OnError,
OnViewportChange,
SelectionRect,
SnapGrid,
ConnectingHandle,
Transform,
XYPosition,
D3ZoomHandler,
type ConnectionStatus,
type CoordinateExtent,
type HandleType,
type NodeDimensionUpdate,
type UpdateNodePositions,
type NodeOrigin,
type OnConnect,
type OnError,
type OnViewportChange,
type SelectionRect,
type SnapGrid,
type ConnectingHandle,
type Transform,
type XYPosition,
type PanZoomInstance,
type PanBy,
OnNodeDrag,
OnSelectionDrag,
OnMoveStart,
OnMove,
OnMoveEnd,
} from '@reactflow/system';
import type {
NodeDragHandler,
Edge,
Node,
NodeChange,
@@ -29,7 +32,6 @@ import type {
NodeInternals,
OnConnectStart,
OnConnectEnd,
SelectionDragHandler,
DefaultEdgeOptions,
FitViewOptions,
OnNodesDelete,
@@ -54,9 +56,7 @@ export type ReactFlowStore = {
paneDragging: boolean;
noPanClassName: string;
d3Zoom: D3ZoomInstance | null;
d3Selection: D3SelectionInstance | null;
d3ZoomHandler: D3ZoomHandler | null;
panZoom: PanZoomInstance | null;
minZoom: number;
maxZoom: number;
translateExtent: CoordinateExtent;
@@ -84,6 +84,7 @@ export type ReactFlowStore = {
edgesUpdatable: boolean;
elementsSelectable: boolean;
elevateNodesOnSelect: boolean;
selectNodesOnDrag: boolean;
multiSelectionActive: boolean;
@@ -91,13 +92,17 @@ export type ReactFlowStore = {
connectionEndHandle: ConnectingHandle | null;
connectionClickStartHandle: ConnectingHandle | null;
onNodeDragStart?: NodeDragHandler;
onNodeDrag?: NodeDragHandler;
onNodeDragStop?: NodeDragHandler;
onNodeDragStart?: OnNodeDrag;
onNodeDrag?: OnNodeDrag;
onNodeDragStop?: OnNodeDrag;
onSelectionDragStart?: SelectionDragHandler;
onSelectionDrag?: SelectionDragHandler;
onSelectionDragStop?: SelectionDragHandler;
onSelectionDragStart?: OnSelectionDrag;
onSelectionDrag?: OnSelectionDrag;
onSelectionDragStop?: OnSelectionDrag;
onMoveStart?: OnMoveStart;
onMove?: OnMove;
onMoveEnd?: OnMoveEnd;
onConnect?: OnConnect;
onConnectStart?: OnConnectStart;
@@ -138,7 +143,7 @@ export type ReactFlowActions = {
setEdges: (edges: Edge[]) => void;
setDefaultNodesAndEdges: (nodes?: Node[], edges?: Edge[]) => void;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
updateNodePositions: (nodeDragItems: NodeDragItem[] | Node[], positionChanged: boolean, dragging: boolean) => void;
updateNodePositions: UpdateNodePositions;
resetSelectedElements: () => void;
unselectNodesAndEdges: (params?: UnselectNodesAndEdgesParams) => void;
addSelectedNodes: (nodeIds: string[]) => void;
@@ -150,7 +155,7 @@ export type ReactFlowActions = {
cancelConnection: () => void;
reset: () => void;
triggerNodeChanges: (changes: NodeChange[]) => void;
panBy: (delta: XYPosition) => boolean;
panBy: PanBy;
};
export type ReactFlowState = ReactFlowStore & ReactFlowActions;