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

View File

@@ -29,11 +29,11 @@
"@types/dagre": "^0.7.48",
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^3.0.1",
"@vitejs/plugin-react": "4.0.0",
"cypress": "^10.6.0",
"cypress-real-events": "^1.7.1",
"start-server-and-test": "^1.14.0",
"typescript": "^4.9.4",
"vite": "^4.0.4"
"vite": "4.3.9"
}
}

View File

@@ -18,7 +18,6 @@
"devDependencies": {
"@changesets/changelog-github": "^0.4.7",
"@changesets/cli": "^2.25.0",
"@preconstruct/cli": "^2.2.1",
"@typescript-eslint/eslint-plugin": "latest",
"@typescript-eslint/parser": "latest",
"autoprefixer": "^10.4.8",
@@ -37,8 +36,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"rimraf": "^3.0.2",
"rollup": "^3.19.1",
"turbo": "^1.8.3",
"rollup": "^3.23.0",
"turbo": "^1.10.0",
"typescript": "^4.9.4"
}
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

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 (

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;

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;

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];
}

View File

@@ -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;

View File

@@ -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;

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;
};

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';

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;
},

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,

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;

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;

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;

View File

@@ -1,11 +1,9 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { memo, useEffect, useRef } from 'react';
import type { MouseEvent } from 'react';
import { memo, useEffect, useRef, type MouseEvent } from 'react';
import cc from 'classcat';
import { shallow } from 'zustand/shallow';
import { zoom, zoomIdentity } from 'd3-zoom';
import type { D3ZoomEvent } from 'd3-zoom';
import { type D3ZoomEvent, zoom } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import {
useStore,
@@ -15,8 +13,9 @@ import {
useStoreApi,
getNodePositionWithOrigin,
CoordinateExtent,
type ReactFlowState,
type Rect,
} from '@reactflow/core';
import type { ReactFlowState, Rect } from '@reactflow/core';
import MiniMapNode from './MiniMapNode';
import type { MiniMapProps, GetMiniMapNodeAttribute } from './types';
@@ -68,7 +67,7 @@ function MiniMap({
zoomable = false,
ariaLabel = 'React Flow mini map',
inversePan = false,
zoomStep = 10
zoomStep = 10,
}: MiniMapProps) {
const store = useStoreApi();
const svg = useRef<SVGSVGElement>(null);
@@ -99,9 +98,9 @@ function MiniMap({
const selection = select(svg.current as Element);
const zoomHandler = (event: D3ZoomEvent<SVGSVGElement, any>) => {
const { transform, d3Selection, d3Zoom } = store.getState();
const { transform, panZoom } = store.getState();
if (event.sourceEvent.type !== 'wheel' || !d3Selection || !d3Zoom) {
if (event.sourceEvent.type !== 'wheel' || !panZoom) {
return;
}
@@ -109,15 +108,15 @@ function MiniMap({
-event.sourceEvent.deltaY *
(event.sourceEvent.deltaMode === 1 ? 0.05 : event.sourceEvent.deltaMode ? 1 : 0.002) *
zoomStep;
const zoom = transform[2] * Math.pow(2, pinchDelta);
const nextZoom = transform[2] * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom);
panZoom.scaleTo(nextZoom);
};
const panHandler = (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { transform, d3Selection, d3Zoom, translateExtent, width, height } = store.getState();
const { transform, panZoom, translateExtent, width, height } = store.getState();
if (event.sourceEvent.type !== 'mousemove' || !d3Selection || !d3Zoom) {
if (event.sourceEvent.type !== 'mousemove' || !panZoom) {
return;
}
@@ -132,10 +131,15 @@ function MiniMap({
[width, height],
];
const nextTransform = zoomIdentity.translate(position.x, position.y).scale(transform[2]);
const constrainedTransform = d3Zoom.constrain()(nextTransform, extent, translateExtent);
d3Zoom.transform(d3Selection, constrainedTransform);
panZoom.setViewportConstrained(
{
x: position.x,
y: position.y,
zoom: transform[2],
},
extent,
translateExtent
);
};
const zoomAndPanHandler = zoom()
@@ -144,7 +148,7 @@ function MiniMap({
// @ts-ignore
.on('zoom.wheel', zoomable ? zoomHandler : null);
selection.call(zoomAndPanHandler);
selection.call(zoomAndPanHandler, {});
return () => {
selection.on('zoom', null);

View File

@@ -39,6 +39,7 @@
},
"dependencies": {
"@reactflow/core": "workspace:^11.6.0",
"@reactflow/utils": "workspace:*",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",

View File

@@ -2,15 +2,8 @@ import { useRef, useEffect, memo } from 'react';
import cc from 'classcat';
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import {
useStoreApi,
useGetPointerPosition,
NodeChange,
NodeDimensionChange,
useNodeId,
NodePositionChange,
clamp,
} from '@reactflow/core';
import { getPointerPosition } from '@reactflow/utils';
import { useStoreApi, NodeChange, NodeDimensionChange, useNodeId, NodePositionChange, clamp } from '@reactflow/core';
import { ResizeDragEvent, ResizeControlProps, ResizeControlLineProps, ResizeControlVariant } from './types';
import { getDirection } from './utils';
@@ -48,7 +41,6 @@ function ResizeControl({
const resizeControlRef = useRef<HTMLDivElement>(null);
const startValues = useRef<typeof initStartValues>(initStartValues);
const prevValues = useRef<typeof initPrevValues>(initPrevValues);
const getPointerPosition = useGetPointerPosition();
const defaultPosition = variant === ResizeControlVariant.Line ? 'right' : 'bottom-right';
const controlPosition = position ?? defaultPosition;
@@ -66,8 +58,9 @@ function ResizeControl({
const dragHandler = drag<HTMLDivElement, unknown>()
.on('start', (event: ResizeDragEvent) => {
const node = store.getState().nodeInternals.get(id);
const { xSnapped, ySnapped } = getPointerPosition(event);
const { nodeInternals, transform, snapGrid, snapToGrid } = store.getState();
const node = nodeInternals.get(id);
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
prevValues.current = {
width: node?.width ?? 0,
@@ -86,8 +79,8 @@ function ResizeControl({
onResizeStart?.(event, { ...prevValues.current });
})
.on('drag', (event: ResizeDragEvent) => {
const { nodeInternals, triggerNodeChanges } = store.getState();
const { xSnapped, ySnapped } = getPointerPosition(event);
const { nodeInternals, transform, snapGrid, snapToGrid, triggerNodeChanges } = store.getState();
const { xSnapped, ySnapped } = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
const node = nodeInternals.get(id);
if (node) {
@@ -221,7 +214,7 @@ function ResizeControl({
return () => {
selection.on('.drag', null);
};
}, [id, controlPosition, minWidth, minHeight, maxWidth, maxHeight, keepAspectRatio, getPointerPosition]);
}, [id, controlPosition, minWidth, minHeight, maxWidth, maxHeight, keepAspectRatio]);
const positionClassNames = controlPosition.split('-');
const colorStyleProp = variant === ResizeControlVariant.Line ? 'borderColor' : 'backgroundColor';

View File

@@ -5,6 +5,7 @@
'customnode',
'drag-n-drop',
'edges',
'interaction',
'overview',
'stress',
'subflows',

View File

@@ -1,136 +1,70 @@
import { get } from 'svelte/store';
import { drag as d3Drag, type D3DragEvent, type SubjectPosition } from 'd3-drag';
import { select } from 'd3-selection';
import type { XYPosition, CoordinateExtent } from '@reactflow/system';
import { XYDrag } from '@reactflow/utils';
import { getDragItems, hasSelector, calcNextPosition } from './utils';
import type { SvelteFlowStore } from '$lib/store/types';
export type UseDragData = { dx: number; dy: number };
export type UseDragEvent = D3DragEvent<HTMLDivElement, null, SubjectPosition>;
export type NodeDragItem = {
id: string;
position: XYPosition;
positionAbsolute: XYPosition;
// distance from the mouse cursor to the node when start dragging
distance: XYPosition;
width?: number | null;
height?: number | null;
extent?: 'parent' | CoordinateExtent;
parentNode?: string;
dragging?: boolean;
};
type UseDragParams = {
store: SvelteFlowStore;
disabled?: boolean;
noDragClassName?: string;
handleSelector?: string;
nodeId?: string;
updateNodePositions: (dragItems: NodeDragItem[], d: boolean, p: boolean) => void;
nodes: SvelteFlowStore['nodes'];
transform: SvelteFlowStore['transform'];
snapGrid: SvelteFlowStore['snapGrid'];
isSelectable?: boolean;
};
export default function drag(
nodeRef: Element,
{
handleSelector,
nodeId,
updateNodePositions,
nodes,
transform: transformStore,
snapGrid: snapGridStore
}: UseDragParams
) {
let dragging = false;
let dragItems: NodeDragItem[] = [];
let lastPos: { x: number | null; y: number | null } = { x: null, y: null };
export default function drag(domNode: Element, params: UseDragParams) {
const dragInstance = XYDrag({
domNode,
getStoreItems: () => {
const { store } = params;
const snapGrid = get(store.snapGrid);
const selection = select(nodeRef);
return {
nodes: get(store.nodes),
edges: get(store.edges),
nodeExtent: get(store.nodeExtent),
snapGrid: snapGrid ? snapGrid : [0, 0],
snapToGrid: !!snapGrid,
nodeOrigin: [0, 0],
multiSelectionActive: false,
domNode: get(store.domNode),
transform: get(store.transform),
autoPanOnNodeDrag: get(store.autoPanOnNodeDrag),
nodesDraggable: get(store.nodesDraggable),
selectNodesOnDrag: get(store.selectNodesOnDrag),
unselectNodesAndEdges: store.unselectNodesAndEdges,
updateNodePositions: store.updateNodePositions,
panBy: store.panBy
};
},
onNodeClick: () => {
console.log('node click');
}
});
const getPointerPosition = ({ sourceEvent }: UseDragEvent) => {
const x = sourceEvent.touches ? sourceEvent.touches[0].clientX : sourceEvent.clientX;
const y = sourceEvent.touches ? sourceEvent.touches[0].clientY : sourceEvent.clientY;
const transform = get(transformStore);
const snapGrid = get(snapGridStore);
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: snapGrid ? snapGrid[0] * Math.round(pointerPos.x / snapGrid[0]) : pointerPos.x,
ySnapped: snapGrid ? snapGrid[1] * Math.round(pointerPos.y / snapGrid[1]) : pointerPos.y,
...pointerPos
};
};
const updateNodes = ({ x, y }: XYPosition) => {
let hasChange = false;
const snapGrid = get(snapGridStore);
dragItems = dragItems.map((n) => {
const nextPosition = { x: x - n.distance.x, y: y - n.distance.y };
if (snapGrid) {
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, get(nodes));
// 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) {
function updateDrag(domNode: Element, params: UseDragParams) {
if (params.disabled) {
dragInstance.destroy();
return;
}
updateNodePositions(dragItems, true, true);
dragging = true;
};
const dragHandler = d3Drag()
.on('start', (event: UseDragEvent) => {
const pointerPos = getPointerPosition(event);
lastPos = pointerPos;
dragItems = getDragItems(get(nodes), pointerPos, nodeId);
})
.on('drag', (event: UseDragEvent) => {
const pointerPos = getPointerPosition(event);
// skip events without movement
if ((lastPos.x !== pointerPos.xSnapped || lastPos.y !== pointerPos.ySnapped) && dragItems) {
lastPos = pointerPos;
updateNodes(pointerPos);
}
})
.on('end', (event: UseDragEvent) => {
dragging = false;
if (dragItems) {
updateNodePositions(dragItems, false, false);
}
})
.filter((event: MouseEvent) => {
const target = event.target as HTMLDivElement;
const isDraggable =
!event.button &&
!hasSelector(target, '.nodrag', nodeRef) &&
(!handleSelector || hasSelector(target, handleSelector, nodeRef));
return isDraggable;
dragInstance.update({
domNode,
noDragClassName: params.noDragClassName,
handleSelector: params.handleSelector,
nodeId: params.nodeId,
isSelectable: params.isSelectable
});
}
selection.call(dragHandler);
updateDrag(domNode, params);
return {
update(params: UseDragParams) {
updateDrag(domNode, params);
},
destroy() {
dragInstance.destroy();
}
};
}

View File

@@ -1,144 +0,0 @@
import {
errorMessages,
type CoordinateExtent,
type NodeDragItem,
type NodeOrigin,
type XYPosition
} from '@reactflow/system';
import { clampPosition, devWarn, getNodePositionWithOrigin, isNumeric } from '@reactflow/utils';
import type { Node } from '$lib/types';
export function isParentSelected(node: Node, nodes: Node[]): boolean {
if (!node.parentNode) {
return false;
}
const parentNode = nodes.find((n) => n.id === node.parentNode);
if (!parentNode) {
return false;
}
if (parentNode.selected) {
return true;
}
return isParentSelected(parentNode, nodes);
}
export function hasSelector(target: Element, selector: string, domNode: Element): boolean {
let current = target;
do {
if (current?.matches(selector)) return true;
if (current === domNode) 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(nodes: Node[], mousePos: XYPosition, nodeId?: string): NodeDragItem[] {
return nodes
.filter(
(n) =>
(n.selected || n.id === nodeId) &&
(n.draggable || n.draggable === undefined) &&
(!n.parentNode || !isParentSelected(n, nodes))
)
.map((n) => ({
id: n.id,
position: n.position ? { ...n.position } : { x: 0, y: 0 },
positionAbsolute: n.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
}));
}
export function calcNextPosition(
node: NodeDragItem | Node,
nextPosition: XYPosition,
nodes: Node[],
nodeExtent?: CoordinateExtent
): { position: XYPosition; positionAbsolute: XYPosition } {
let currentExtent = node.extent || nodeExtent;
if (node.extent === 'parent') {
if (node.parentNode && node.width && node.height) {
const parent = nodes.find((n) => n.id === node.parentNode);
const parentOrigin = parent?.origin || [0, 0];
const nodeOrigin = node.origin || [0, 0];
const { x: parentX, y: parentY } = getNodePositionWithOrigin(
parent,
parentOrigin
).positionAbsolute;
console.log({
parentX,
parentY,
parentW: parent?.width,
parentH: parent?.height,
nodeW: node.width,
nodeH: node.height,
parentOrigin: parentOrigin[0]
});
currentExtent =
parent &&
isNumeric(parentX) &&
isNumeric(parentY) &&
isNumeric(parent.width) &&
isNumeric(parent.height)
? [
[parentX + node.width * nodeOrigin[0], parentY + node.height * nodeOrigin[1]],
[
parentX + parent.width - node.width + node.width * nodeOrigin[0],
parentY + parent.height - node.height + node.height * nodeOrigin[1]
]
]
: currentExtent;
} else {
devWarn('005', errorMessages['error005']());
currentExtent = nodeExtent;
}
} else if (node.extent && node.parentNode) {
const parent = nodes.find((n) => n.id === node.parentNode);
const { x: parentX, y: parentY } = getNodePositionWithOrigin(
parent,
parent?.origin || [0, 0]
).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 parent = nodes.find((n) => n.id === node.parentNode);
parentPosition = getNodePositionWithOrigin(parent, parent?.origin || [0, 0]).positionAbsolute;
}
const positionAbsolute = currentExtent
? clampPosition(nextPosition, currentExtent as CoordinateExtent)
: nextPosition;
return {
position: {
x: positionAbsolute.x - parentPosition.x,
y: positionAbsolute.y - parentPosition.y
},
positionAbsolute
};
}

View File

@@ -1,217 +1,62 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { Writable } from 'svelte/store';
import { select } from 'd3-selection';
import { zoom as d3Zoom, zoomIdentity, type D3ZoomEvent } from 'd3-zoom';
import type {
D3SelectionInstance,
D3ZoomInstance,
OnMove,
OnMoveEnd,
OnMoveStart,
CoordinateExtent,
OnPanZoom,
PanOnScrollMode,
PanZoomInstance,
Transform,
Viewport
} from '@reactflow/system';
import { clamp } from '@reactflow/utils';
const isWrappedWithClass = (event: any, className: string | undefined) =>
event.target.closest(`.${className}`);
const eventToFlowTransform = (eventViewport: any): Viewport => ({
x: eventViewport.x,
y: eventViewport.y,
zoom: eventViewport.k
});
const isRightClickPan = (panOnDrag: FlowRendererProps['panOnDrag'], usedButton: number) =>
usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2);
const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x ||
prevViewport.y !== eventViewport.y ||
prevViewport.zoom !== eventViewport.k;
function filter(event: any, params: ZoomParams): boolean {
const zoomScroll = true;
const pinchZoom = true;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, 'svelte-flow__node') ||
isWrappedWithClass(event, 'svelte-flow__edge'))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
// if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
// return false;
// }
// // during a selection we prevent all other interactions
if (params.selecting) {
return false;
}
// // if zoom on double click is disabled, we prevent the double click event
// if (!zoomOnDoubleClick && event.type === 'dblclick') {
// return false;
// }
// // if the target element is inside an element with the nowheel class, we prevent zooming
// if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
// return false;
// }
// // if the target element is inside an element with the nopan class, we prevent panning
if (isWrappedWithClass(event, 'nopan') && event.type !== 'wheel') {
return false;
}
// if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
// return false;
// }
// // when there is no scroll handling enabled, we prevent all wheel events
// if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
// return false;
// }
// // if the pane is not movable, we prevent dragging it with mousestart or touchstart
// if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
// return false;
// }
// // if the pane is only movable using allowed clicks
// if (
// Array.isArray(panOnDrag) &&
// !panOnDrag.includes(event.button) &&
// (event.type === 'mousedown' || event.type === 'touchstart')
// ) {
// return false;
// }
// // We only allow right clicks if pan on drag is set to right click
// const buttonAllowed =
// (Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) ||
// !event.button ||
// event.button <= 1;
// default filter for d3-zoom
return ((!event.ctrlKey || event.type === 'wheel') && !event.button) || event.button <= 1;
}
import { XYPanZoom } from '@reactflow/utils';
type ZoomParams = {
transform: Writable<Transform>;
selecting: boolean;
d3: Writable<{ zoom: D3ZoomInstance | null; selection: D3SelectionInstance | null }>;
minZoom: number;
maxZoom: number;
initialViewport: Viewport;
dragging: Writable<boolean>;
onMoveStart?: OnMoveStart;
onMove?: OnMove;
onMoveEnd?: OnMoveEnd;
onPanZoomStart?: OnPanZoom;
onPanZoom?: OnPanZoom;
onPanZoomEnd?: OnPanZoom;
onPaneContextMenu?: (event: MouseEvent) => void;
translateExtent: CoordinateExtent;
panZoom: Writable<PanZoomInstance | null>;
zoomOnScroll: boolean;
zoomOnPinch: boolean;
zoomOnDoubleClick: boolean;
panOnScroll: boolean;
panOnDrag: boolean | number[];
panOnScrollSpeed: number;
panOnScrollMode: PanOnScrollMode;
zoomActivationKeyPressed: boolean;
preventScrolling: boolean;
noPanClassName: string;
noWheelClassName: string;
userSelectionActive: boolean;
};
export default function zoom(domNode: Element, params: ZoomParams) {
const { d3, minZoom, maxZoom, initialViewport } = params;
const { panZoom, minZoom, maxZoom, initialViewport, transform, dragging, translateExtent } =
params;
const d3ZoomInstance = d3Zoom().scaleExtent([minZoom, maxZoom]);
const selection = select(domNode).call(d3ZoomInstance);
const updatedTransform = zoomIdentity
.translate(initialViewport.x, initialViewport.y)
.scale(clamp(initialViewport.zoom, minZoom, maxZoom));
const d3ZoomHandler = selection.on('wheel.zoom');
let mouseButton = 0;
let isZoomingOrPanning = false;
let prevTransform: Viewport = { x: 0, y: 0, zoom: 0 };
let zoomedWithRightMouseButton = false;
let timerId: any;
function updateZoomHandling(_params: ZoomParams) {
const { transform, dragging, onMoveStart, onMove, onMoveEnd } = _params;
d3ZoomInstance.on('start', (event: D3ZoomEvent<HTMLDivElement, any>) => {
// we need to remember it here, because it's always 0 in the "zoom" event
mouseButton = event.sourceEvent?.button || 0;
isZoomingOrPanning = true;
if (event.sourceEvent?.type === 'mousedown') {
dragging.set(true);
}
if (onMoveStart) {
const flowTransform = eventToFlowTransform(event.transform);
prevTransform = flowTransform;
onMoveStart?.((event.sourceEvent as MouseEvent | TouchEvent) || null, flowTransform);
}
});
d3ZoomInstance.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
transform.set([event.transform.x, event.transform.y, event.transform.k]);
if (onMove) {
const flowTransform = eventToFlowTransform(event.transform);
onMove?.((event.sourceEvent as MouseEvent | TouchEvent) || null, flowTransform);
}
});
d3ZoomInstance.on('end', (event: D3ZoomEvent<HTMLDivElement, any>) => {
isZoomingOrPanning = false;
dragging.set(false);
if (
//onPaneContextMenu &&
isRightClickPan(panOnDrag, mouseButton ?? 0) &&
!zoomedWithRightMouseButton
) {
// onPaneContextMenu(event.sourceEvent);
}
zoomedWithRightMouseButton = false;
if (onMoveEnd && viewChanged(prevTransform, event.transform)) {
const flowTransform = eventToFlowTransform(event.transform);
prevTransform = flowTransform;
clearTimeout(timerId);
timerId = setTimeout(
() => {
onMoveEnd?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
},
panOnScroll ? 150 : 0
);
}
});
selection.on('wheel.zoom', function (event: any, d: any) {
if (isWrappedWithClass(event, 'nowheel')) {
return null;
}
event.preventDefault();
d3ZoomHandler!.call(this, event, d);
});
d3ZoomInstance.filter((event: any) => filter(event, params));
}
d3ZoomInstance.transform(selection, updatedTransform);
updateZoomHandling(params);
d3.set({
zoom: d3ZoomInstance,
selection
const panZoomInstance = XYPanZoom({
domNode,
minZoom,
maxZoom,
translateExtent,
viewport: initialViewport,
onTransformChange: transform.set,
onDraggingChange: dragging.set
});
const { x, y, zoom } = panZoomInstance.getViewport();
transform.set([x, y, zoom]);
panZoom.set(panZoomInstance);
panZoomInstance.update(params);
return {
update(params: ZoomParams) {
updateZoomHandling(params);
panZoomInstance.update(params);
}
};
}

View File

@@ -5,7 +5,8 @@
import { Selection } from '$lib/components/Selection';
import drag from '$lib/actions/drag';
const { selectionRectMode, nodes, transform, snapGrid, updateNodePositions } = useStore();
const store = useStore();
const { selectionRectMode, nodes } = store;
$: selectedNodes = $nodes.filter((n) => n.selected);
$: rect = getRectOfNodes(selectedNodes);
@@ -15,7 +16,7 @@
<div
class="selection-wrapper nopan"
style={`width: ${rect.width}px; height: ${rect.height}px; transform: translate(${rect.x}px, ${rect.y}px)`}
use:drag={{ nodes, snapGrid, transform, updateNodePositions }}
use:drag={{ disabled: false, store }}
/>
<Selection
isVisible={$selectionRectMode === 'nodes'}

View File

@@ -31,7 +31,8 @@
let className: string = '';
export { className as class };
const { nodes, transform, nodeTypes, snapGrid, updateNodePositions, addSelectedNodes } = useStore();
const store = useStore();
const { nodes, nodeTypes, addSelectedNodes } = store;
let nodeRef: HTMLDivElement;
const nodeTypeValid = !!$nodeTypes[type!];
@@ -74,7 +75,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
use:drag={{ nodeId: id, nodes, snapGrid, transform, updateNodePositions }}
use:drag={{ nodeId: id, isSelectable: selectable, disabled: false, handleSelector: undefined, noDragClassName: 'nodrag', store }}
bind:this={nodeRef}
data-id={id}
class={cc(['svelte-flow__node', `svelte-flow__node-${type || 'default'}`, className])}

View File

@@ -48,7 +48,7 @@
selectionRectMode,
selectionKeyPressed,
selectionMode,
resetSelectedElements,
unselectNodesAndEdges,
} = useStore();
let container: HTMLDivElement;
@@ -61,7 +61,7 @@
function onClick(event: MouseEvent) {
dispatch('pane:click', event);
resetSelectedElements();
unselectNodesAndEdges();
selectionRectMode.set(null);
}
@@ -80,7 +80,7 @@
const { x, y } = getEventPosition(event, containerBounds);
resetSelectedElements();
unselectNodesAndEdges();
selectionRect.set({
width: 0,

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import cc from 'classcat';
import { PanOnScrollMode, type CoordinateExtent, type Viewport } from '@reactflow/system';
import { Zoom } from '$lib/container/Zoom';
import { Pane } from '$lib/container/Pane';
import { Viewport } from '$lib/container/Viewport';
import { Viewport as ViewportComponent } from '$lib/container/Viewport';
import { NodeRenderer } from '$lib/container/NodeRenderer';
import { EdgeRenderer } from '$lib/container/EdgeRenderer';
import { UserSelection } from '$lib/components/UserSelection';
@@ -14,6 +15,7 @@
import { Attribution } from '$lib/components/Attribution';
import { useStore } from '$lib/store';
import type { SvelteFlowProps } from './types';
import type { EdgeTypes, NodeTypes } from '$lib/types';
type $$Props = SvelteFlowProps;
@@ -21,7 +23,7 @@
export let fitView: $$Props['fitView'] = undefined;
export let minZoom: $$Props['minZoom'] = undefined;
export let maxZoom: $$Props['maxZoom'] = undefined;
export let initialViewport: $$Props['initialViewport'] = undefined;
export let initialViewport: Viewport = { x:0, y: 0, zoom: 1 };
export let nodeTypes: $$Props['nodeTypes'] = undefined;
export let edgeTypes: $$Props['edgeTypes'] = undefined;
export let selectionKey: $$Props['selectionKey'] = undefined;
@@ -37,6 +39,15 @@
export let onMove: $$Props['onMove'] = undefined;
export let onMoveEnd: $$Props['onMoveEnd'] = undefined;
export let isValidConnection: $$Props['isValidConnection'] = undefined;
export let translateExtent: $$Props['translateExtent'] = undefined;
export let panOnScrollMode: PanOnScrollMode = PanOnScrollMode.Free;
export let preventScrolling: boolean = true;
export let zoomOnScroll: boolean = true;
export let zoomOnDoubleClick: boolean = true;
export let zoomOnPinch: boolean = true;
export let panOnScroll: boolean = false;
export let panOnDrag: boolean | number[] = true;
export let defaultMarkerColor = '#b1b1b7';
export let style: $$Props['style'] = undefined;
@@ -53,6 +64,15 @@
store.height.set(height);
store.domNode.set(domNode);
updateStore({
nodeTypes,
edgeTypes,
minZoom,
maxZoom,
translateExtent,
fitView
});
return () => {
store.reset();
}
@@ -81,7 +101,14 @@
})
}
$: {
function updateStore({ nodeTypes, edgeTypes, minZoom, maxZoom, translateExtent, fitView }: {
nodeTypes?: NodeTypes,
edgeTypes?: EdgeTypes,
minZoom?: number,
maxZoom?: number,
translateExtent?: CoordinateExtent,
fitView?: boolean
}) {
if (nodeTypes !== undefined) {
store.setNodeTypes(nodeTypes);
}
@@ -98,10 +125,23 @@
store.setMaxZoom(maxZoom);
}
if (translateExtent !== undefined) {
store.setTranslateExtent(translateExtent)
}
if (fitView !== undefined) {
store.fitViewOnInit.set(fitView);
}
}
$: updateStore({
nodeTypes,
edgeTypes,
minZoom,
maxZoom,
translateExtent,
fitView
})
</script>
<div
@@ -114,9 +154,21 @@
{...$$restProps}
>
<KeyHandler {selectionKey} {deleteKey} />
<Zoom {initialViewport} {onMoveStart} {onMove} {onMoveEnd}>
<Zoom
{initialViewport}
{onMoveStart}
{onMove}
{onMoveEnd}
{panOnScrollMode}
{preventScrolling}
{zoomOnScroll}
{zoomOnDoubleClick}
{zoomOnPinch}
{panOnScroll}
{panOnDrag}
>
<Pane on:pane:click>
<Viewport>
<ViewportComponent>
<EdgeRenderer on:edge:click />
<ConnectionLine />
<div class="svelte-flow__edgelabel-renderer" />
@@ -130,7 +182,7 @@
on:connect:end
/>
<NodeSelection />
</Viewport>
</ViewportComponent>
<UserSelection />
</Pane>
</Zoom>

View File

@@ -9,7 +9,9 @@ import type {
SnapGrid,
OnMoveStart,
OnMove,
OnMoveEnd
OnMoveEnd,
CoordinateExtent,
PanOnScrollMode
} from '@reactflow/system';
import type {
@@ -39,6 +41,14 @@ export type SvelteFlowProps = DOMAttributes<HTMLDivElement> & {
nodesDraggable?: boolean;
nodesConnectable?: boolean;
elementsSelectable?: boolean;
translateExtent?: CoordinateExtent;
panOnScrollMode?: PanOnScrollMode;
preventScrolling?: boolean;
zoomOnScroll?: boolean;
zoomOnDoubleClick?: boolean;
zoomOnPinch?: boolean;
panOnScroll?: boolean;
panOnDrag?: boolean | number[];
class?: string;
style?: string;

View File

@@ -1,32 +1,60 @@
<script lang="ts">
import { PanOnScrollMode } from '@reactflow/system';
import { useStore } from '$lib/store';
import zoom from '$lib/actions/zoom';
import type { ZoomProps } from './types';
type $$Props = ZoomProps;
export let initialViewport: $$Props['initialViewport'] = undefined;
export let initialViewport: $$Props['initialViewport'];
export let onMoveStart: $$Props['onMoveStart'] = undefined;
export let onMove: $$Props['onMove'] = undefined;
export let onMoveEnd: $$Props['onMoveEnd'] = undefined;
export let panOnScrollMode: $$Props['panOnScrollMode'];
export let preventScrolling: $$Props['preventScrolling'];;
export let zoomOnScroll: $$Props['zoomOnScroll'];;
export let zoomOnDoubleClick: $$Props['zoomOnDoubleClick'];;
export let zoomOnPinch: $$Props['zoomOnPinch'];;
export let panOnDrag: $$Props['panOnDrag'];
export let panOnScroll: $$Props['panOnScroll'];;
const { transform, d3, selectionKeyPressed, selectionRectMode, minZoom, maxZoom, dragging } = useStore();
const {
transform,
panZoom,
selectionKeyPressed,
minZoom,
maxZoom,
dragging,
translateExtent
} = useStore();
$: viewPort = initialViewport || { x: 0, y: 0, zoom: 1 };
$: selecting = $selectionKeyPressed || $selectionRectMode === 'user';
</script>
<div class="svelte-flow__zoom" use:zoom={{
transform,
d3,
selecting,
minZoom: $minZoom,
maxZoom: $maxZoom,
initialViewport: viewPort,
dragging,
onMoveStart,
onMove,
onMoveEnd
panZoom,
onPanZoomStart: onMoveStart,
onPanZoom: onMove,
onPanZoomEnd: onMoveEnd,
zoomOnScroll,
zoomOnDoubleClick,
zoomOnPinch,
panOnScroll,
panOnDrag,
panOnScrollSpeed: 0.5,
panOnScrollMode: panOnScrollMode || PanOnScrollMode.Free,
zoomActivationKeyPressed: false,
preventScrolling: typeof preventScrolling === 'boolean' ? preventScrolling : true,
noPanClassName: 'nopan',
noWheelClassName: 'nowheel',
userSelectionActive: $selectionKeyPressed,
translateExtent: $translateExtent
}}>
<slot />
</div>

View File

@@ -1,8 +1,15 @@
import type { OnMove, OnMoveStart, OnMoveEnd, Viewport } from '@reactflow/system';
import type { OnMoveStart, OnMove, OnMoveEnd, PanOnScrollMode, Viewport } from '@reactflow/system';
export type ZoomProps = {
initialViewport?: Viewport;
initialViewport: Viewport;
panOnScrollMode: PanOnScrollMode;
onMove?: OnMove;
onMoveStart?: OnMoveStart;
onMoveEnd?: OnMoveEnd;
preventScrolling: boolean;
zoomOnScroll: boolean;
zoomOnDoubleClick: boolean;
zoomOnPinch: boolean;
panOnScroll: boolean;
panOnDrag: boolean | number[];
};

View File

@@ -7,12 +7,11 @@ import type {
XYPosition,
ZoomInOut
} from '@reactflow/system';
import { zoomIdentity } from 'd3-zoom';
import { pointToRendererPoint } from '@reactflow/utils';
import { useStore } from '$lib/store';
import type { FitViewOptions } from '$lib/types';
import type { SvelteFlowStore } from '$lib/store/types';
import { getD3Transition, pointToRendererPoint } from '@reactflow/utils';
export function useSvelteFlow(): {
zoomIn: ZoomInOut;
@@ -38,7 +37,7 @@ export function useSvelteFlow(): {
width,
height,
maxZoom,
d3,
panZoom,
nodes,
edges
} = useStore();
@@ -62,23 +61,20 @@ export function useSvelteFlow(): {
zoomIn,
zoomOut,
setZoom: (zoomLevel, options) => {
const { zoom, selection } = get(d3);
if (zoom && selection) {
zoom.scaleTo(getD3Transition(selection, options?.duration), zoomLevel);
}
get(panZoom)?.scaleTo(zoomLevel, { duration: options?.duration });
},
getZoom: () => get(transform)[2],
setViewport: (viewport, options) => {
const [x, y, zoom] = get(transform);
const { zoom: d3Zoom, selection } = get(d3);
if (d3Zoom && selection) {
const nextTransform = zoomIdentity
.translate(viewport.x ?? x, viewport.y ?? y)
.scale(viewport.zoom ?? zoom);
d3Zoom.transform(getD3Transition(selection, options?.duration), nextTransform);
}
get(panZoom)?.setViewport(
{
x: viewport.x ?? x,
y: viewport.y ?? y,
zoom: viewport.zoom ?? zoom
},
{ duration: options?.duration }
);
},
getViewport: () => {
const [x, y, zoom] = get(transform);
@@ -88,16 +84,17 @@ export function useSvelteFlow(): {
const _width = get(width);
const _height = get(height);
const _maxZoom = get(maxZoom);
const { zoom, selection } = get(d3);
if (zoom && selection) {
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);
const nextZoom = typeof options?.zoom !== 'undefined' ? options.zoom : _maxZoom;
zoom.transform(getD3Transition(selection, options?.duration), transform);
}
get(panZoom)?.setViewport(
{
x: _width / 2 - x * nextZoom,
y: _height / 2 - y * nextZoom,
zoom: nextZoom
},
{ duration: options?.duration }
);
},
fitView,
project: (position: XYPosition) => {

View File

@@ -1,8 +1,7 @@
import { getContext } from 'svelte';
import { derived, get } from 'svelte/store';
import { zoomIdentity } from 'd3-zoom';
import {
type NodeDragItem,
type UpdateNodePositions,
type NodeDimensionUpdate,
internalsSymbol,
type ViewportHelperFunctionOptions,
@@ -13,10 +12,10 @@ import {
import {
createMarkerIds,
fitView as fitViewUtil,
getD3Transition,
getDimensions,
getElementsToRemove,
getHandleBounds
getHandleBounds,
infiniteExtent
} from '@reactflow/utils';
import { addEdge as addEdgeUtil } from '$lib/utils';
@@ -60,7 +59,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
store.edges.set(addEdgeUtil(edgeParams, edges));
}
function updateNodePositions(nodeDragItems: NodeDragItem[], dragging = false) {
const updateNodePositions: UpdateNodePositions = (nodeDragItems, dragging = false) => {
store.nodes.update((nds) => {
return nds.map((n) => {
const nodeDragItem = nodeDragItems.find((ndi) => ndi.id === n.id);
@@ -77,7 +76,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
return n;
});
});
}
};
function updateNodeDimensions(updates: NodeDimensionUpdate[]) {
const viewportNode = document?.querySelector('.svelte-flow__viewport');
@@ -123,21 +122,21 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
return node;
});
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
const panZoom = get(store.panZoom);
const fitViewOnInitDone =
get(store.fitViewOnInitDone) ||
(get(store.fitViewOnInit) && !!d3Zoom && !!d3Selection && fitView({ nodes: nextNodes }));
(get(store.fitViewOnInit) && !!panZoom && fitView({ nodes: nextNodes }));
store.fitViewOnInitDone.set(fitViewOnInitDone);
store.nodes.set(nextNodes);
}
function zoomBy(factor: number, options?: ViewportHelperFunctionOptions) {
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
const panZoom = get(store.panZoom);
if (d3Zoom && d3Selection) {
d3Zoom.scaleBy(getD3Transition(d3Selection, options?.duration), factor);
if (panZoom) {
panZoom.scaleBy(factor, options);
}
}
@@ -150,28 +149,36 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
}
function setMinZoom(minZoom: number) {
const d3Zoom = get(store.d3).zoom;
if (d3Zoom) {
d3Zoom?.scaleExtent([minZoom, get(store.maxZoom)]);
const panZoom = get(store.panZoom);
if (panZoom) {
panZoom.setScaleExtent([minZoom, get(store.maxZoom)]);
store.minZoom.set(minZoom);
}
}
function setMaxZoom(maxZoom: number) {
const d3Zoom = get(store.d3).zoom;
if (d3Zoom) {
d3Zoom?.scaleExtent([get(store.minZoom), maxZoom]);
const panZoom = get(store.panZoom);
if (panZoom) {
panZoom.setScaleExtent([get(store.minZoom), maxZoom]);
store.maxZoom.set(maxZoom);
}
}
function fitView(options?: FitViewOptions) {
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
function setTranslateExtent(extent: CoordinateExtent) {
const panZoom = get(store.panZoom);
if (!d3Zoom || !d3Selection) {
if (panZoom) {
panZoom.setTranslateExtent(extent);
store.translateExtent.set(extent);
}
}
function fitView(options?: FitViewOptions) {
const panZoom = get(store.panZoom);
if (!panZoom) {
return false;
}
@@ -184,8 +191,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
height: get(store.height),
minZoom: 0.2,
maxZoom: 2,
d3Selection,
d3Zoom,
panZoom,
nodeOrigin: [0, 0]
},
{}
@@ -203,7 +209,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
return item;
}
function resetSelectedElements() {
function unselectNodesAndEdges() {
store.nodes.update((ns) => ns.map(resetSelectedItem));
store.edges.update((es) => es.map(resetSelectedItem));
}
@@ -267,29 +273,35 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
}
function panBy(delta: XYPosition) {
const { zoom: d3Zoom, selection: d3Selection } = get(store.d3);
const panZoom = get(store.panZoom);
const transform = get(store.transform);
const width = get(store.width);
const height = get(store.height);
if (!d3Zoom || !d3Selection || (!delta.x && !delta.y)) {
return;
if (!panZoom || (!delta.x && !delta.y)) {
return false;
}
const nextTransform = zoomIdentity
.translate(transform[0] + delta.x, transform[1] + delta.y)
.scale(transform[2]);
const nextViewport = panZoom.setViewportConstrained(
{
x: transform[0] + delta.x,
y: transform[1] + delta.y,
zoom: transform[2]
},
[
[0, 0],
[width, height]
],
infiniteExtent
);
const extent: CoordinateExtent = [
[0, 0],
[width, height]
];
const transformChanged =
!!nextViewport &&
(nextViewport.x !== transform[0] ||
nextViewport.y !== transform[1] ||
nextViewport.k !== transform[2]);
const constrainedTransform = d3Zoom?.constrain()(nextTransform, extent, [
[Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY],
[Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY]
]);
d3Zoom.transform(d3Selection, constrainedTransform);
return transformChanged;
}
function updateConnection(connectionUpdate: Partial<ConnectionData> | null) {
@@ -319,7 +331,7 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
store.snapGrid.set(null);
store.isValidConnection.set(() => true);
resetSelectedElements();
unselectNodesAndEdges();
cancelConnection();
}
@@ -346,7 +358,8 @@ export function createStore(params: CreateStoreParams): SvelteFlowStore {
fitView,
setMinZoom,
setMaxZoom,
resetSelectedElements,
setTranslateExtent,
unselectNodesAndEdges,
addSelectedNodes,
addSelectedEdges,
panBy,

View File

@@ -1,14 +1,14 @@
import { readable, writable } from 'svelte/store';
import {
SelectionMode,
type D3ZoomInstance,
type D3SelectionInstance,
ConnectionMode,
ConnectionLineType,
type SelectionRect,
type Transform,
type SnapGrid,
type MarkerProps
type MarkerProps,
type PanZoomInstance,
type CoordinateExtent
} from '@reactflow/system';
import DefaultNode from '$lib/components/nodes/DefaultNode.svelte';
@@ -27,6 +27,7 @@ import type {
Node,
IsValidConnection
} from '$lib/types';
import { infiniteExtent } from '@reactflow/utils';
export const initConnectionData = {
nodeId: null,
@@ -58,12 +59,12 @@ export const initialStoreState = {
width: writable<number>(500),
minZoom: writable<number>(0.5),
maxZoom: writable<number>(2),
nodeExtent: writable<CoordinateExtent>(infiniteExtent),
translateExtent: writable<CoordinateExtent>(infiniteExtent),
autoPanOnNodeDrag: writable<boolean>(true),
fitViewOnInit: writable<boolean>(false),
fitViewOnInitDone: writable<boolean>(false),
d3: writable<{ zoom: D3ZoomInstance | null; selection: D3SelectionInstance | null }>({
zoom: null,
selection: null
}),
panZoom: writable<PanZoomInstance | null>(null),
snapGrid: writable<SnapGrid | null>(null),
dragging: writable<boolean>(false),
selectionRect: writable<SelectionRect | null>(null),
@@ -85,6 +86,7 @@ export const initialStoreState = {
nodesDraggable: writable<boolean>(true),
nodesConnectable: writable<boolean>(true),
elementsSelectable: writable<boolean>(true),
selectNodesOnDrag: writable<boolean>(true),
markers: readable<MarkerProps[]>([]),
defaultMarkerColor: writable<string>('#b1b1b7')
};

View File

@@ -3,7 +3,8 @@ import type {
XYPosition,
ViewportHelperFunctionOptions,
Connection,
NodeDragItem
UpdateNodePositions,
CoordinateExtent
} from '@reactflow/system';
import type { initialStoreState } from './initial-store';
@@ -18,17 +19,14 @@ export type SvelteFlowStoreActions = {
zoomOut: (options?: ViewportHelperFunctionOptions) => void;
setMinZoom: (minZoom: number) => void;
setMaxZoom: (maxZoom: number) => void;
setTranslateExtent: (extent: CoordinateExtent) => void;
fitView: (options?: FitViewOptions) => boolean;
updateNodePositions: (
nodeDragItems: NodeDragItem[],
positionChanged?: boolean,
dragging?: boolean
) => void;
updateNodePositions: UpdateNodePositions;
updateNodeDimensions: (updates: NodeDimensionUpdate[]) => void;
resetSelectedElements: () => void;
unselectNodesAndEdges: () => void;
addSelectedNodes: (ids: string[]) => void;
addSelectedEdges: (ids: string[]) => void;
panBy: (delta: XYPosition) => void;
panBy: (delta: XYPosition) => boolean;
updateConnection: (connection: Partial<ConnectionData>) => void;
cancelConnection: () => void;
reset(): void;

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import SvelteFlow, {
SvelteFlowProvider,
Controls,
Panel,
createNodes,
createEdges,
PanOnScrollMode,
Minimap,
type OnMoveEnd,
type Node,
type Edge,
} from '../../lib/index';
const nodes = createNodes([
{
id: '1',
type: 'input',
data: { label: 'Node 1' },
position: { x: 250, y: 5 },
},
{ id: '2', data: { label: 'Node 2' }, position: { x: 100, y: 100 } },
{ id: '3', data: { label: 'Node 3' }, position: { x: 400, y: 100 } },
{ id: '4', data: { label: 'Node 4' }, position: { x: 400, y: 200 } },
]);
const edges = createEdges([
{ id: 'e1-2', source: '1', target: '2', animated: true },
{ id: 'e1-3', source: '1', target: '3' },
]);
const onNodeDragStart = (_: MouseEvent, node: Node) => console.log('drag start', node);
const onNodeDragStop = (_: MouseEvent, node: Node) => console.log('drag stop', node);
const onNodeClick = (_: MouseEvent, node: Node) => console.log('click', node);
const onEdgeClick = (_: MouseEvent, edge: Edge) => console.log('click', edge);
const onPaneClick = (event: MouseEvent) => console.log('onPaneClick', event);
const onPaneScroll = (event?: WheelEvent) => console.log('onPaneScroll', event);
const onPaneContextMenu = (event: MouseEvent) => console.log('onPaneContextMenu', event);
const onMoveEnd: OnMoveEnd = (_, viewport) => console.log('onMoveEnd', viewport);
let isSelectable = false;
let isDraggable = false;
let isConnectable = false;
let zoomOnScroll = false;
let zoomOnPinch = false;
let panOnScroll = false;
let panOnScrollMode = PanOnScrollMode.Free;
let zoomOnDoubleClick = false;
let panOnDrag = true;
let captureZoomClick = false;
let captureZoomScroll = false;
let captureElementClick = false;
</script>
<SvelteFlowProvider
{nodes}
{edges}
>
<SvelteFlow
elementsSelectable={isSelectable}
nodesConnectable={isConnectable}
nodesDraggable={isDraggable}
zoomOnScroll={zoomOnScroll}
zoomOnPinch={zoomOnPinch}
panOnScroll={panOnScroll}
panOnScrollMode={panOnScrollMode}
zoomOnDoubleClick={zoomOnDoubleClick}
panOnDrag={panOnDrag}
onMoveEnd={onMoveEnd}
>
<Minimap />
<Controls />
<Panel position="top-right">
<div>
<label for="draggable">
nodesDraggable
<input
id="draggable"
type="checkbox"
bind:checked={isDraggable}
class="react-flow__draggable"
/>
</label>
</div>
<div>
<label for="connectable">
nodesConnectable
<input
id="connectable"
type="checkbox"
bind:checked={isConnectable}
class="react-flow__connectable"
/>
</label>
</div>
<div>
<label for="selectable">
elementsSelectable
<input
id="selectable"
type="checkbox"
bind:checked={isSelectable}
class="react-flow__selectable"
/>
</label>
</div>
<div>
<label for="zoomonscroll">
zoomOnScroll
<input
id="zoomonscroll"
type="checkbox"
bind:checked={zoomOnScroll}
class="react-flow__zoomonscroll"
/>
</label>
</div>
<div>
<label for="zoomonpinch">
zoomOnPinch
<input
id="zoomonpinch"
type="checkbox"
bind:checked={zoomOnPinch}
class="react-flow__zoomonpinch"
/>
</label>
</div>
<div>
<label for="panonscroll">
panOnScroll
<input
id="panonscroll"
type="checkbox"
bind:checked={panOnScroll}
class="react-flow__panonscroll"
/>
</label>
</div>
<div>
<label for="panonscrollmode">
panOnScrollMode
<select
id="panonscrollmode"
bind:value={panOnScrollMode}
on:change={(event) => { panOnScrollMode = PanOnScrollMode.Free }}
class="react-flow__panonscrollmode"
>
<option value="free">free</option>
<option value="horizontal">horizontal</option>
<option value="vertical">vertical</option>
</select>
</label>
</div>
<div>
<label for="zoomondbl">
zoomOnDoubleClick
<input
id="zoomondbl"
type="checkbox"
bind:checked={zoomOnDoubleClick}
class="react-flow__zoomondbl"
/>
</label>
</div>
<div>
<label for="panondrag">
panOnDrag
<input
id="panondrag"
type="checkbox"
bind:checked={panOnDrag}
class="react-flow__panondrag"
/>
</label>
</div>
<div>
<label for="capturezoompaneclick">
capture onPaneClick
<input
id="capturezoompaneclick"
type="checkbox"
bind:checked={captureZoomClick}
class="react-flow__capturezoompaneclick"
/>
</label>
</div>
<div>
<label for="capturezoompanescroll">
capture onPaneScroll
<input
id="capturezoompanescroll"
type="checkbox"
bind:checked={captureZoomScroll}
class="react-flow__capturezoompanescroll"
/>
</label>
</div>
<div>
<label for="captureelementclick">
capture onElementClick
<input
id="captureelementclick"
type="checkbox"
bind:checked={captureElementClick}
class="react-flow__captureelementclick"
/>
</label>
</div>
</Panel>
</SvelteFlow>
</SvelteFlowProvider>

View File

@@ -11,8 +11,8 @@
type Edge
} from '../../lib/index';
const yNodes = 10;
const xNodes = 10;
const yNodes = 20;
const xNodes = 20;
const nodeItems: Node[] = [];
const edgeItems: Edge[] = [];
@@ -55,6 +55,7 @@
>
<SvelteFlow
fitView
minZoom={0.2}
>
<Controls />
<Background variant={BackgroundVariant.Dots} />

View File

@@ -2,8 +2,9 @@
import type { D3DragEvent, Selection as D3Selection, SubjectPosition, ZoomBehavior } from 'd3';
import type { XYPosition, Rect } from './utils';
import type { BaseNode, NodeOrigin } from './nodes';
import type { BaseNode, NodeDragItem, NodeOrigin } from './nodes';
import type { HandleType } from './handles';
import { PanZoomInstance } from './panzoom';
export type Project = (position: XYPosition) => XYPosition;
@@ -39,8 +40,7 @@ export type FitViewParamsBase<NodeType extends BaseNode> = {
nodes: NodeType[];
width: number;
height: number;
d3Zoom: D3ZoomInstance;
d3Selection: D3SelectionInstance;
panZoom: PanZoomInstance;
minZoom: number;
maxZoom: number;
nodeOrigin?: NodeOrigin;
@@ -117,3 +117,10 @@ export type SelectionRect = Rect & {
};
export type OnError = (id: string, message: string) => void;
export type UpdateNodePositions = (
dragItems: NodeDragItem[] | BaseNode[],
positionChanged?: boolean,
dragging?: boolean
) => void;
export type PanBy = (delta: XYPosition) => boolean;

View File

@@ -3,3 +3,4 @@ export * from './nodes';
export * from './edges';
export * from './handles';
export * from './utils';
export * from './panzoom';

View File

@@ -84,3 +84,7 @@ export type NodeDragItem = {
};
export type NodeOrigin = [number, number];
export type OnNodeDrag = (event: MouseEvent, node: BaseNode, nodes: BaseNode[]) => void;
export type OnSelectionDrag = (event: MouseEvent, nodes: BaseNode[]) => void;

View File

@@ -0,0 +1,56 @@
import { ZoomTransform } from 'd3-zoom';
import { CoordinateExtent, PanOnScrollMode, Transform, Viewport } from './';
export type OnDraggingChange = (dragging: boolean) => void;
export type OnTransformChange = (transform: Transform) => void;
export type PanZoomParams = {
domNode: Element;
minZoom: number;
maxZoom: number;
viewport: Viewport;
translateExtent: CoordinateExtent;
onTransformChange: OnTransformChange;
onDraggingChange: OnDraggingChange;
onPanZoomStart?: OnPanZoom;
onPanZoom?: OnPanZoom;
onPanZoomEnd?: OnPanZoom;
};
export type PanZoomTransformOptions = {
duration?: number;
};
export type OnPanZoom = (event: MouseEvent | TouchEvent | null, viewport: Viewport) => void;
export type PanZoomUpdateOptions = {
noWheelClassName: string;
noPanClassName: string;
onPaneContextMenu?: (event: MouseEvent) => void;
preventScrolling: boolean;
panOnScroll: boolean;
panOnDrag: boolean | number[];
panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number;
userSelectionActive: boolean;
zoomOnPinch: boolean;
zoomOnScroll: boolean;
zoomOnDoubleClick: boolean;
zoomActivationKeyPressed: boolean;
};
export type PanZoomInstance = {
update: (params: PanZoomUpdateOptions) => void;
destroy: () => void;
getViewport: () => Viewport;
setViewport: (viewport: Viewport, options?: PanZoomTransformOptions) => ZoomTransform | undefined;
setViewportConstrained: (
viewport: Viewport,
extent: CoordinateExtent,
translateExtent: CoordinateExtent
) => ZoomTransform | undefined;
setScaleExtent: (scaleExtent: [number, number]) => void;
setTranslateExtent: (translateExtent: CoordinateExtent) => void;
scaleTo: (scale: number, options?: PanZoomTransformOptions) => void;
scaleBy: (factor: number, options?: PanZoomTransformOptions) => void;
};

View File

@@ -41,6 +41,8 @@
"@types/d3-drag": "^3.0.1",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"devDependencies": {

View File

@@ -1,7 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { zoomIdentity } from 'd3-zoom';
import { boxToRect, clamp, devWarn, getBoundsOfBoxes, getOverlappingArea, rectToBox } from './utils';
import {
boxToRect,
clamp,
clampPosition,
devWarn,
getBoundsOfBoxes,
getOverlappingArea,
isNumeric,
rectToBox,
} from './utils';
import {
errorMessages,
type Connection,
@@ -13,7 +20,10 @@ import {
type BaseEdge,
type FitViewParamsBase,
type FitViewOptionsBase,
type D3SelectionInstance,
SnapGrid,
NodeDragItem,
CoordinateExtent,
OnError,
} from '@reactflow/system';
export const isEdgeBase = <NodeType extends BaseNode = BaseNode, EdgeType extends BaseEdge = BaseEdge>(
@@ -293,12 +303,8 @@ export const getTransformForBounds = (
return [x, y, clampedZoom];
};
export const getD3Transition = (selection: D3SelectionInstance, duration = 0) => {
return selection.transition().duration(duration);
};
export function fitView<Params extends FitViewParamsBase<BaseNode>, Options extends FitViewOptionsBase<BaseNode>>(
{ nodes, width, height, d3Zoom, d3Selection, minZoom, maxZoom, nodeOrigin = [0, 0] }: Params,
{ nodes, width, height, panZoom, minZoom, maxZoom, nodeOrigin = [0, 0] }: Params,
options?: Options
) {
const filteredNodes = nodes.filter((n) => {
@@ -325,16 +331,97 @@ export function fitView<Params extends FitViewParamsBase<BaseNode>, Options exte
options?.padding ?? 0.1
);
const nextTransform = zoomIdentity.translate(x, y).scale(zoom);
if (typeof options?.duration === 'number' && options.duration > 0) {
d3Zoom.transform(getD3Transition(d3Selection, options.duration), nextTransform);
} else {
d3Zoom.transform(d3Selection, nextTransform);
}
panZoom.setViewport({ x, y, zoom }, { duration: options?.duration });
return true;
}
return false;
}
export type GetPointerPositionParams = {
transform: Transform;
snapGrid?: SnapGrid;
snapToGrid?: boolean;
};
export function getPointerPosition(
event: MouseEvent | TouchEvent,
{ snapGrid = [0, 0], snapToGrid = false, transform }: GetPointerPositionParams
): XYPosition & { xSnapped: number; ySnapped: number } {
const x = 'touches' in event ? event.touches[0].clientX : event.clientX;
const y = 'touches' in event ? event.touches[0].clientY : event.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,
};
}
export function calcNextPosition<NodeType extends BaseNode>(
node: NodeDragItem | NodeType,
nextPosition: XYPosition,
nodes: NodeType[],
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 = nodes.find((n) => n.id === 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 = nodes.find((n) => n.id === 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 = nodes.find((n) => n.id === 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,
};
}

View File

@@ -1,4 +1,5 @@
export * from './graph';
export * from './utils';
export * from './marker';
export * from './panzoom';
export * from './xypanzoom';
export * from './xydrag';

View File

@@ -1,352 +0,0 @@
import { D3ZoomEvent, zoom, zoomIdentity } from 'd3-zoom';
import { select, pointer } from 'd3-selection';
import {
PanOnScrollMode,
type CoordinateExtent,
type Transform,
type Viewport,
D3ZoomInstance,
D3SelectionInstance,
D3ZoomHandler,
OnMoveStart,
OnMove,
OnMoveEnd,
} from '@reactflow/system';
import { clamp } from './utils';
const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k;
const eventToFlowTransform = (eventViewport: any): Viewport => ({
x: eventViewport.x,
y: eventViewport.y,
zoom: eventViewport.k,
});
const isWrappedWithClass = (event: any, className: string | undefined) => event.target.closest(`.${className}`);
const isRightClickPan = (panOnDrag: boolean | number[], usedButton: number) =>
usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2);
export type InitPanZoomParams = {
domNode: Element;
minZoom: number;
maxZoom: number;
defaultViewport: Viewport;
translateExtent: CoordinateExtent;
};
export function initPanZoom({ domNode, minZoom, maxZoom, translateExtent, defaultViewport }: InitPanZoomParams): {
d3ZoomInstance: D3ZoomInstance;
d3Selection: D3SelectionInstance;
d3ZoomHandler: D3ZoomHandler | null;
transform: Transform;
} {
const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
const d3Selection = select(domNode).call(d3ZoomInstance);
const updatedTransform = zoomIdentity
.translate(defaultViewport.x, defaultViewport.y)
.scale(clamp(defaultViewport.zoom, minZoom, maxZoom));
const extent: CoordinateExtent = [
[0, 0],
[bbox.width, bbox.height],
];
const constrainedTransform = d3ZoomInstance.constrain()(updatedTransform, extent, translateExtent);
d3ZoomInstance.transform(d3Selection, constrainedTransform);
const d3ZoomHandler = d3Selection.on('wheel.zoom') || null;
return {
d3ZoomInstance,
d3Selection,
d3ZoomHandler,
// we need to pass transform because zoom handler is not registered when we set the initial transform
transform: [constrainedTransform.x, constrainedTransform.y, constrainedTransform.k],
};
}
type PanOnScrollParams = {
noWheelClassName: string;
d3Selection: D3SelectionInstance;
d3Zoom: D3ZoomInstance;
panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number;
zoomOnPinch: boolean;
};
export function createPanOnScrollHandler({
noWheelClassName,
d3Selection,
d3Zoom,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
}: PanOnScrollParams) {
return (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1;
if (event.ctrlKey && zoomOnPinch) {
const point = pointer(event);
// taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js
const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10;
const zoom = currentZoom * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom, point);
return;
}
// increase scroll speed in firefox
// firefox: deltaMode === 1; chrome: deltaMode === 0
const deltaNormalize = event.deltaMode === 1 ? 20 : 1;
const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize;
d3Zoom.translateBy(
d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed,
-(deltaY / currentZoom) * panOnScrollSpeed
);
};
}
type ZoomOnScrollParams = {
noWheelClassName: string;
preventScrolling: boolean;
d3ZoomHandler: D3ZoomHandler;
};
export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler }: ZoomOnScrollParams) {
return function (this: Element, event: any, d: unknown) {
if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) {
return null;
}
event.preventDefault();
d3ZoomHandler.call(this, event, d);
};
}
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
zoomedWithRightMouseButton: boolean;
prevTransform: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
export type PanZoomStartParams = {
zoomPanValues: ZoomPanValues;
setDragging: (isDragging: boolean) => void;
onMoveStart?: OnMoveStart;
onViewportChangeStart?: (viewport: Viewport) => void;
};
export function createPanZoomStartHandler({
zoomPanValues,
setDragging,
onMoveStart,
onViewportChangeStart,
}: PanZoomStartParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
// we need to remember it here, because it's always 0 in the "zoom" event
zoomPanValues.mouseButton = event.sourceEvent?.button || 0;
zoomPanValues.isZoomingOrPanning = true;
if (event.sourceEvent?.type === 'mousedown') {
setDragging(true);
}
if (onMoveStart || onViewportChangeStart) {
const flowTransform = eventToFlowTransform(event.transform);
zoomPanValues.prevTransform = flowTransform;
onViewportChangeStart?.(flowTransform);
onMoveStart?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
}
};
}
export type PanZoomParams = {
zoomPanValues: ZoomPanValues;
panOnDrag: boolean | number[];
onPaneContextMenu: boolean;
setTransform: (transform: Transform) => void;
onMove?: OnMove;
onViewportChange?: (viewport: Viewport) => void;
};
export function createPanZoomHandler({
zoomPanValues,
panOnDrag,
onPaneContextMenu,
setTransform,
onMove,
onViewportChange,
}: PanZoomParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
zoomPanValues.zoomedWithRightMouseButton = !!(
onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0)
);
setTransform([event.transform.x, event.transform.y, event.transform.k]);
if (onMove || onViewportChange) {
const flowTransform = eventToFlowTransform(event.transform);
onViewportChange?.(flowTransform);
onMove?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
}
};
}
export type PanZoomEndParams = {
zoomPanValues: ZoomPanValues;
panOnDrag: boolean | number[];
panOnScroll: boolean;
setDragging: (isDragging: boolean) => void;
onMoveEnd?: OnMoveEnd;
onViewportChangeEnd?: (viewport: Viewport) => void;
onPaneContextMenu?: (event: any) => void;
};
export function createPanZoomEndHandler({
zoomPanValues,
panOnDrag,
panOnScroll,
setDragging,
onMoveEnd,
onViewportChangeEnd,
onPaneContextMenu,
}: PanZoomEndParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
zoomPanValues.isZoomingOrPanning = false;
if (
onPaneContextMenu &&
isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) &&
!zoomPanValues.zoomedWithRightMouseButton &&
event.sourceEvent
) {
onPaneContextMenu(event.sourceEvent);
}
zoomPanValues.zoomedWithRightMouseButton = false;
setDragging(false);
if ((onMoveEnd || onViewportChangeEnd) && viewChanged(zoomPanValues.prevTransform, event.transform)) {
const flowTransform = eventToFlowTransform(event.transform);
zoomPanValues.prevTransform = flowTransform;
clearTimeout(zoomPanValues.timerId);
zoomPanValues.timerId = setTimeout(
() => {
onViewportChangeEnd?.(flowTransform);
onMoveEnd?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
},
panOnScroll ? 150 : 0
);
}
};
}
type FilterParams = {
zoomActivationKeyPressed: boolean;
zoomOnScroll: boolean;
zoomOnPinch: boolean;
panOnDrag: boolean | number[];
panOnScroll: boolean;
zoomOnDoubleClick: boolean;
userSelectionActive: boolean;
noWheelClassName: string;
noPanClassName: string;
};
export function createFilter({
zoomActivationKeyPressed,
zoomOnScroll,
zoomOnPinch,
panOnDrag,
panOnScroll,
zoomOnDoubleClick,
userSelectionActive,
noWheelClassName,
noPanClassName,
}: FilterParams) {
return (event: any): boolean => {
const zoomScroll = zoomActivationKeyPressed || zoomOnScroll;
const pinchZoom = zoomOnPinch && event.ctrlKey;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, 'react-flow__node') || isWrappedWithClass(event, 'react-flow__edge'))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
return false;
}
// during a selection we prevent all other interactions
if (userSelectionActive) {
return false;
}
// if zoom on double click is disabled, we prevent the double click event
if (!zoomOnDoubleClick && event.type === 'dblclick') {
return false;
}
// if the target element is inside an element with the nowheel class, we prevent zooming
if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
return false;
}
// if the target element is inside an element with the nopan class, we prevent panning
if (isWrappedWithClass(event, noPanClassName) && event.type !== 'wheel') {
return false;
}
if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
return false;
}
// when there is no scroll handling enabled, we prevent all wheel events
if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
return false;
}
// if the pane is not movable, we prevent dragging it with mousestart or touchstart
if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
return false;
}
// if the pane is only movable using allowed clicks
if (
Array.isArray(panOnDrag) &&
!panOnDrag.includes(event.button) &&
(event.type === 'mousedown' || event.type === 'touchstart')
) {
return false;
}
// We only allow right clicks if pan on drag is set to right click
const buttonAllowed =
(Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) || !event.button || event.button <= 1;
// default filter for d3-zoom
return (!event.ctrlKey || event.type === 'wheel') && buttonAllowed;
};
}

View File

@@ -0,0 +1,269 @@
import { drag } from 'd3-drag';
import { select } from 'd3-selection';
import type {
BaseNode,
NodeDragItem,
UseDragEvent,
XYPosition,
BaseEdge,
CoordinateExtent,
NodeOrigin,
OnError,
SnapGrid,
Transform,
PanBy,
OnNodeDrag,
OnSelectionDrag,
UpdateNodePositions,
} from '@reactflow/system';
import { calcAutoPan, getEventPosition, getPointerPosition, calcNextPosition } from '../';
import { getDragItems, getEventHandlerParams, hasSelector, wrapSelectionDragFunc } from './utils';
export type OnDrag = (event: MouseEvent, dragItems: NodeDragItem[], node: BaseNode, nodes: BaseNode[]) => void;
type StoreItems = {
nodes: BaseNode[];
edges: BaseEdge[];
nodeExtent: CoordinateExtent;
snapGrid: SnapGrid;
snapToGrid: boolean;
nodeOrigin: NodeOrigin;
multiSelectionActive: boolean;
domNode?: Element | null;
transform: Transform;
autoPanOnNodeDrag: boolean;
nodesDraggable: boolean;
selectNodesOnDrag: boolean;
panBy: PanBy;
unselectNodesAndEdges: () => void;
onError?: OnError;
onNodeDragStart?: OnNodeDrag;
onNodeDrag?: OnNodeDrag;
onNodeDragStop?: OnNodeDrag;
onSelectionDragStart?: OnSelectionDrag;
onSelectionDrag?: OnSelectionDrag;
onSelectionDragStop?: OnSelectionDrag;
updateNodePositions: UpdateNodePositions;
};
export type XYDragParams = {
domNode: Element;
getStoreItems: () => StoreItems;
onDragStart?: OnDrag;
onDrag?: OnDrag;
onDragStop?: OnDrag;
onNodeClick?: () => void;
};
export type XYDragInstance = {
update: (params: DragUpdateParams) => void;
destroy: () => void;
};
export type DragUpdateParams = {
noDragClassName?: string;
handleSelector?: string;
isSelectable?: boolean;
nodeId?: string;
domNode: Element;
};
export function XYDrag({
domNode,
onNodeClick,
getStoreItems,
onDragStart,
onDrag,
onDragStop,
}: XYDragParams): XYDragInstance {
let lastPos: { x: number | null; y: number | null } = { x: null, y: null };
let autoPanId = 0;
let dragItems: NodeDragItem[] = [];
let autoPanStarted = false;
let mousePosition: XYPosition = { x: 0, y: 0 };
let dragEvent: MouseEvent | null = null;
let containerBounds: DOMRect | null = null;
const d3Selection = select(domNode);
// public functions
function update({ noDragClassName, handleSelector, domNode, isSelectable, nodeId }: DragUpdateParams) {
function updateNodes({ x, y }: XYPosition) {
const {
nodes,
nodeExtent,
snapGrid,
snapToGrid,
nodeOrigin,
onNodeDrag,
onSelectionDrag,
onError,
updateNodePositions,
} = getStoreItems();
lastPos = { x, y };
let hasChange = false;
dragItems = dragItems.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, nodes, 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, true, true);
const onNodeOrSelectionDrag = nodeId ? onNodeDrag : wrapSelectionDragFunc(onSelectionDrag);
if (dragEvent) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDrag?.(dragEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDrag?.(dragEvent as MouseEvent, currentNode, currentNodes);
}
}
function autoPan() {
if (!containerBounds) {
return;
}
const [xMovement, yMovement] = calcAutoPan(mousePosition, containerBounds);
if (xMovement !== 0 || yMovement !== 0) {
const { transform, panBy } = getStoreItems();
lastPos.x = (lastPos.x ?? 0) - xMovement / transform[2];
lastPos.y = (lastPos.y ?? 0) - yMovement / transform[2];
if (panBy({ x: xMovement, y: yMovement })) {
updateNodes(lastPos as XYPosition);
}
}
autoPanId = requestAnimationFrame(autoPan);
}
const d3DragInstance = drag()
.on('start', (event: UseDragEvent) => {
const {
nodes,
multiSelectionActive,
domNode,
nodesDraggable,
transform,
snapGrid,
snapToGrid,
onNodeDragStart,
onSelectionDragStart,
unselectNodesAndEdges,
selectNodesOnDrag,
} = getStoreItems();
if (!selectNodesOnDrag && !multiSelectionActive && nodeId) {
if (!nodes.find((n) => n.id === nodeId)?.selected) {
// we need to reset selected nodes when selectNodesOnDrag=false
unselectNodesAndEdges();
}
}
if (isSelectable && selectNodesOnDrag) {
onNodeClick?.();
}
const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
lastPos = pointerPos;
dragItems = getDragItems(nodes, nodesDraggable, pointerPos, nodeId);
const onNodeOrSelectionDragStart = nodeId ? onNodeDragStart : wrapSelectionDragFunc(onSelectionDragStart);
if (dragItems) {
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDragStart?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDragStart?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
}
containerBounds = domNode?.getBoundingClientRect() || null;
mousePosition = getEventPosition(event.sourceEvent, containerBounds!);
})
.on('drag', (event: UseDragEvent) => {
const { autoPanOnNodeDrag, transform, snapGrid, snapToGrid } = getStoreItems();
const pointerPos = getPointerPosition(event.sourceEvent, { transform, snapGrid, snapToGrid });
if (!autoPanStarted && autoPanOnNodeDrag) {
autoPanStarted = true;
autoPan();
}
// skip events without movement
if ((lastPos.x !== pointerPos.xSnapped || lastPos.y !== pointerPos.ySnapped) && dragItems) {
dragEvent = event.sourceEvent as MouseEvent;
mousePosition = getEventPosition(event.sourceEvent, containerBounds!);
updateNodes(pointerPos);
}
})
.on('end', (event: UseDragEvent) => {
autoPanStarted = false;
cancelAnimationFrame(autoPanId);
if (dragItems) {
const { nodes, updateNodePositions, onNodeDragStop, onSelectionDragStop } = getStoreItems();
const onNodeOrSelectionDragStop = nodeId ? onNodeDragStop : wrapSelectionDragFunc(onSelectionDragStop);
updateNodePositions(dragItems, false, false);
const [currentNode, currentNodes] = getEventHandlerParams({
nodeId,
dragItems,
nodes,
});
onDragStop?.(event.sourceEvent as MouseEvent, dragItems, currentNode, currentNodes);
onNodeOrSelectionDragStop?.(event.sourceEvent as MouseEvent, currentNode, currentNodes);
}
})
.filter((event: MouseEvent) => {
const target = event.target as HTMLDivElement;
const isDraggable =
!event.button &&
(!noDragClassName || !hasSelector(target, `.${noDragClassName}`, domNode)) &&
(!handleSelector || hasSelector(target, handleSelector, domNode));
return isDraggable;
});
d3Selection.call(d3DragInstance);
}
function destroy() {
d3Selection.on('.drag', null);
}
return {
update,
destroy,
};
}

View File

@@ -0,0 +1 @@
export * from './XYDrag';

View File

@@ -0,0 +1,94 @@
import { type NodeDragItem, type XYPosition, BaseNode } from '@reactflow/system';
export function wrapSelectionDragFunc(selectionFunc?: (event: MouseEvent, nodes: BaseNode[]) => void) {
return (event: MouseEvent, _: BaseNode, nodes: BaseNode[]) => selectionFunc?.(event, nodes);
}
export function isParentSelected<NodeType extends BaseNode>(node: NodeType, nodes: NodeType[]): boolean {
if (!node.parentNode) {
return false;
}
const parentNode = nodes.find((node) => node.id === node.parentNode);
if (!parentNode) {
return false;
}
if (parentNode.selected) {
return true;
}
return isParentSelected(parentNode, nodes);
}
export function hasSelector(target: Element, selector: string, domNode: Element): boolean {
let current = target;
do {
if (current?.matches(selector)) return true;
if (current === domNode) 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<NodeType extends BaseNode>(
nodes: NodeType[],
nodesDraggable: boolean,
mousePos: XYPosition,
nodeId?: string
): NodeDragItem[] {
return nodes
.filter(
(n) =>
(n.selected || n.id === nodeId) &&
(!n.parentNode || !isParentSelected(n, nodes)) &&
(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,
}));
}
// 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<NodeType extends BaseNode>({
nodeId,
dragItems,
nodes,
}: {
nodeId?: string;
dragItems: NodeDragItem[];
nodes: NodeType[];
}): [NodeType, NodeType[]] {
const extentedDragItems: NodeType[] = dragItems.map((n) => {
const node = nodes.find((node) => node.id === n.id)!;
return {
...node,
position: n.position,
positionAbsolute: n.positionAbsolute,
};
});
return [nodeId ? extentedDragItems.find((n) => n.id === nodeId)! : extentedDragItems[0], extentedDragItems];
}

View File

@@ -0,0 +1,222 @@
import { type ZoomTransform, zoom, zoomTransform } from 'd3-zoom';
import { select } from 'd3-selection';
import {
type CoordinateExtent,
type Viewport,
PanZoomTransformOptions,
PanZoomUpdateOptions,
PanZoomParams,
PanZoomInstance,
} from '@reactflow/system';
import { clamp } from '../utils';
import { getD3Transition, viewportToTransform } from './utils';
import {
createPanOnScrollHandler,
createPanZoomEndHandler,
createPanZoomHandler,
createPanZoomStartHandler,
createZoomOnScrollHandler,
} from './eventhandler';
import { createFilter } from './filter';
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
export function XYPanZoom({
domNode,
minZoom,
maxZoom,
translateExtent,
viewport,
onPanZoom,
onPanZoomStart,
onPanZoomEnd,
onTransformChange,
onDraggingChange,
}: PanZoomParams): PanZoomInstance {
const zoomPanValues: ZoomPanValues = {
isZoomingOrPanning: false,
usedRightMouseButton: false,
prevViewport: { x: 0, y: 0, zoom: 0 },
mouseButton: 0,
timerId: undefined,
};
const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
const d3Selection = select(domNode).call(d3ZoomInstance);
setViewportConstrained(
{
x: viewport.x,
y: viewport.y,
zoom: clamp(viewport.zoom, minZoom, maxZoom),
},
[
[0, 0],
[bbox.width, bbox.height],
],
translateExtent
);
const d3ZoomHandler = d3Selection.on('wheel.zoom')!;
function setTransform(transform: ZoomTransform, options?: PanZoomTransformOptions) {
if (d3Selection) {
d3ZoomInstance?.transform(getD3Transition(d3Selection, options?.duration), transform);
}
}
// public functions
function update({
noWheelClassName,
noPanClassName,
onPaneContextMenu,
userSelectionActive,
panOnScroll,
panOnDrag,
panOnScrollMode,
panOnScrollSpeed,
preventScrolling,
zoomOnPinch,
zoomOnScroll,
zoomOnDoubleClick,
zoomActivationKeyPressed,
}: PanZoomUpdateOptions) {
if (userSelectionActive && !zoomPanValues.isZoomingOrPanning) {
destroy();
}
const isPanOnScroll = panOnScroll && !zoomActivationKeyPressed && !userSelectionActive;
const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({
noWheelClassName,
d3Selection,
d3Zoom: d3ZoomInstance,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
})
: createZoomOnScrollHandler({
noWheelClassName,
preventScrolling,
d3ZoomHandler,
});
d3Selection.on('wheel.zoom', wheelHandler, { passive: false });
if (!userSelectionActive) {
// pan zoom start
const startHandler = createPanZoomStartHandler({
zoomPanValues,
onDraggingChange,
onPanZoomStart,
});
d3ZoomInstance.on('start', startHandler);
// pan zoom
const panZoomHandler = createPanZoomHandler({
zoomPanValues,
panOnDrag,
onPaneContextMenu: !!onPaneContextMenu,
onPanZoom,
onTransformChange,
});
d3ZoomInstance.on('zoom', panZoomHandler);
// pan zoom end
const panZoomEndHandler = createPanZoomEndHandler({
zoomPanValues,
panOnDrag,
panOnScroll,
onPaneContextMenu,
onPanZoomEnd,
onDraggingChange,
});
d3ZoomInstance.on('end', panZoomEndHandler);
}
const filter = createFilter({
zoomActivationKeyPressed,
panOnDrag,
zoomOnScroll,
panOnScroll,
zoomOnDoubleClick,
zoomOnPinch,
userSelectionActive,
noPanClassName,
noWheelClassName,
});
d3ZoomInstance.filter(filter);
}
function destroy() {
d3ZoomInstance.on('zoom', null);
}
function setViewportConstrained(
viewport: Viewport,
extent: CoordinateExtent,
translateExtent: CoordinateExtent
): ZoomTransform | undefined {
const nextTransform = viewportToTransform(viewport);
const contrainedTransform = d3ZoomInstance?.constrain()(nextTransform, extent, translateExtent);
if (contrainedTransform) {
setTransform(contrainedTransform);
}
return contrainedTransform;
}
function setViewport(viewport: Viewport, options?: PanZoomTransformOptions) {
const nextTransform = viewportToTransform(viewport);
setTransform(nextTransform, options);
return nextTransform;
}
function getViewport(): Viewport {
const transform = d3Selection ? zoomTransform(d3Selection.node() as Element) : { x: 0, y: 0, k: 1 };
return { x: transform.x, y: transform.y, zoom: transform.k };
}
function scaleTo(zoom: number, options?: PanZoomTransformOptions) {
if (d3Selection) {
d3ZoomInstance?.scaleTo(getD3Transition(d3Selection, options?.duration), zoom);
}
}
function scaleBy(factor: number, options?: PanZoomTransformOptions) {
if (d3Selection) {
d3ZoomInstance?.scaleBy(getD3Transition(d3Selection, options?.duration), factor);
}
}
function setScaleExtent(scaleExtent: [number, number]) {
d3ZoomInstance?.scaleExtent(scaleExtent);
}
function setTranslateExtent(translateExtent: CoordinateExtent) {
d3ZoomInstance?.translateExtent(translateExtent);
}
return {
update,
destroy,
setViewport,
setViewportConstrained,
getViewport,
scaleTo,
scaleBy,
setScaleExtent,
setTranslateExtent,
};
}

View File

@@ -0,0 +1,192 @@
import type { D3ZoomEvent } from 'd3-zoom';
import { pointer } from 'd3-selection';
import {
PanOnScrollMode,
D3SelectionInstance,
D3ZoomHandler,
D3ZoomInstance,
OnPanZoom,
Viewport,
OnDraggingChange,
OnTransformChange,
} from '@reactflow/system';
import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged } from './utils';
export type ZoomPanValues = {
isZoomingOrPanning: boolean;
usedRightMouseButton: boolean;
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType<typeof setTimeout> | undefined;
};
export type PanOnScrollParams = {
noWheelClassName: string;
d3Selection: D3SelectionInstance;
d3Zoom: D3ZoomInstance;
panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number;
zoomOnPinch: boolean;
};
export type ZoomOnScrollParams = {
noWheelClassName: string;
preventScrolling: boolean;
d3ZoomHandler: D3ZoomHandler;
};
export type PanZoomStartParams = {
zoomPanValues: ZoomPanValues;
onDraggingChange: OnDraggingChange;
onPanZoomStart?: OnPanZoom;
};
export type PanZoomParams = {
zoomPanValues: ZoomPanValues;
panOnDrag: boolean | number[];
onPaneContextMenu: boolean;
onTransformChange: OnTransformChange;
onPanZoom?: OnPanZoom;
};
export type PanZoomEndParams = {
zoomPanValues: ZoomPanValues;
panOnDrag: boolean | number[];
panOnScroll: boolean;
onDraggingChange: (isDragging: boolean) => void;
onPanZoomEnd?: OnPanZoom;
onPaneContextMenu?: (event: any) => void;
};
export function createPanOnScrollHandler({
noWheelClassName,
d3Selection,
d3Zoom,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
}: PanOnScrollParams) {
return (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) {
return false;
}
event.preventDefault();
event.stopImmediatePropagation();
const currentZoom = d3Selection.property('__zoom').k || 1;
if (event.ctrlKey && zoomOnPinch) {
const point = pointer(event);
// taken from https://github.com/d3/d3-zoom/blob/master/src/zoom.js
const pinchDelta = -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002) * 10;
const zoom = currentZoom * Math.pow(2, pinchDelta);
d3Zoom.scaleTo(d3Selection, zoom, point);
return;
}
// increase scroll speed in firefox
// firefox: deltaMode === 1; chrome: deltaMode === 0
const deltaNormalize = event.deltaMode === 1 ? 20 : 1;
const deltaX = panOnScrollMode === PanOnScrollMode.Vertical ? 0 : event.deltaX * deltaNormalize;
const deltaY = panOnScrollMode === PanOnScrollMode.Horizontal ? 0 : event.deltaY * deltaNormalize;
d3Zoom.translateBy(
d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed,
-(deltaY / currentZoom) * panOnScrollSpeed
);
};
}
export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling, d3ZoomHandler }: ZoomOnScrollParams) {
return function (this: Element, event: any, d: unknown) {
if (!preventScrolling || isWrappedWithClass(event, noWheelClassName)) {
return null;
}
event.preventDefault();
d3ZoomHandler.call(this, event, d);
};
}
export function createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart }: PanZoomStartParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
// we need to remember it here, because it's always 0 in the "zoom" event
zoomPanValues.mouseButton = event.sourceEvent?.button || 0;
zoomPanValues.isZoomingOrPanning = true;
if (event.sourceEvent?.type === 'mousedown') {
onDraggingChange(true);
}
if (onPanZoomStart) {
const viewport = transformToViewport(event.transform);
zoomPanValues.prevViewport = viewport;
onPanZoomStart?.(event.sourceEvent as MouseEvent | TouchEvent, viewport);
}
};
}
export function createPanZoomHandler({
zoomPanValues,
panOnDrag,
onPaneContextMenu,
onTransformChange,
onPanZoom,
}: PanZoomParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
zoomPanValues.usedRightMouseButton = !!(
onPaneContextMenu && isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0)
);
onTransformChange([event.transform.x, event.transform.y, event.transform.k]);
if (onPanZoom) {
onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));
}
};
}
export function createPanZoomEndHandler({
zoomPanValues,
panOnDrag,
panOnScroll,
onDraggingChange,
onPanZoomEnd,
onPaneContextMenu,
}: PanZoomEndParams) {
return (event: D3ZoomEvent<HTMLDivElement, any>) => {
zoomPanValues.isZoomingOrPanning = false;
if (
onPaneContextMenu &&
isRightClickPan(panOnDrag, zoomPanValues.mouseButton ?? 0) &&
!zoomPanValues.usedRightMouseButton &&
event.sourceEvent
) {
onPaneContextMenu(event.sourceEvent);
}
zoomPanValues.usedRightMouseButton = false;
onDraggingChange(false);
if (onPanZoomEnd && viewChanged(zoomPanValues.prevViewport, event.transform)) {
const viewport = transformToViewport(event.transform);
zoomPanValues.prevViewport = viewport;
clearTimeout(zoomPanValues.timerId);
zoomPanValues.timerId = setTimeout(
() => {
onPanZoomEnd?.(event.sourceEvent as MouseEvent | TouchEvent, viewport);
},
// we need a setTimeout for panOnScroll to supress multiple end events fired during scroll
panOnScroll ? 150 : 0
);
}
};
}

View File

@@ -0,0 +1,93 @@
import { isWrappedWithClass } from './utils';
export type FilterParams = {
zoomActivationKeyPressed: boolean;
zoomOnScroll: boolean;
zoomOnPinch: boolean;
panOnDrag: boolean | number[];
panOnScroll: boolean;
zoomOnDoubleClick: boolean;
userSelectionActive: boolean;
noWheelClassName: string;
noPanClassName: string;
};
export function createFilter({
zoomActivationKeyPressed,
zoomOnScroll,
zoomOnPinch,
panOnDrag,
panOnScroll,
zoomOnDoubleClick,
userSelectionActive,
noWheelClassName,
noPanClassName,
}: FilterParams) {
return (event: any): boolean => {
const zoomScroll = zoomActivationKeyPressed || zoomOnScroll;
const pinchZoom = zoomOnPinch && event.ctrlKey;
if (
event.button === 1 &&
event.type === 'mousedown' &&
(isWrappedWithClass(event, 'react-flow__node') || isWrappedWithClass(event, 'react-flow__edge'))
) {
return true;
}
// if all interactions are disabled, we prevent all zoom events
if (!panOnDrag && !zoomScroll && !panOnScroll && !zoomOnDoubleClick && !zoomOnPinch) {
return false;
}
// during a selection we prevent all other interactions
if (userSelectionActive) {
return false;
}
// if zoom on double click is disabled, we prevent the double click event
if (!zoomOnDoubleClick && event.type === 'dblclick') {
return false;
}
// if the target element is inside an element with the nowheel class, we prevent zooming
if (isWrappedWithClass(event, noWheelClassName) && event.type === 'wheel') {
return false;
}
// if the target element is inside an element with the nopan class, we prevent panning
if (isWrappedWithClass(event, noPanClassName) && event.type !== 'wheel') {
return false;
}
if (!zoomOnPinch && event.ctrlKey && event.type === 'wheel') {
return false;
}
// when there is no scroll handling enabled, we prevent all wheel events
if (!zoomScroll && !panOnScroll && !pinchZoom && event.type === 'wheel') {
return false;
}
// if the pane is not movable, we prevent dragging it with mousestart or touchstart
if (!panOnDrag && (event.type === 'mousedown' || event.type === 'touchstart')) {
return false;
}
// if the pane is only movable using allowed clicks
if (
Array.isArray(panOnDrag) &&
!panOnDrag.includes(event.button) &&
(event.type === 'mousedown' || event.type === 'touchstart')
) {
return false;
}
// We only allow right clicks if pan on drag is set to right click
const buttonAllowed =
(Array.isArray(panOnDrag) && panOnDrag.includes(event.button)) || !event.button || event.button <= 1;
// default filter for d3-zoom
return (!event.ctrlKey || event.type === 'wheel') && buttonAllowed;
};
}

View File

@@ -0,0 +1 @@
export * from './XYPanZoom';

View File

@@ -0,0 +1,22 @@
import { type D3SelectionInstance, type Viewport } from '@reactflow/system';
import { type ZoomTransform, zoomIdentity } from 'd3-zoom';
export const viewChanged = (prevViewport: Viewport, eventViewport: any): boolean =>
prevViewport.x !== eventViewport.x || prevViewport.y !== eventViewport.y || prevViewport.zoom !== eventViewport.k;
export const transformToViewport = (transform: ZoomTransform): Viewport => ({
x: transform.x,
y: transform.y,
zoom: transform.k,
});
export const viewportToTransform = ({ x, y, zoom }: Viewport): ZoomTransform =>
zoomIdentity.translate(x, y).scale(zoom);
export const isWrappedWithClass = (event: any, className: string | undefined) => event.target.closest(`.${className}`);
export const isRightClickPan = (panOnDrag: boolean | number[], usedButton: number) =>
usedButton === 2 && Array.isArray(panOnDrag) && panOnDrag.includes(2);
export const getD3Transition = (selection: D3SelectionInstance, duration = 0) =>
typeof duration === 'number' && duration > 0 ? selection.transition().duration(duration) : selection;

3922
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,12 @@
"main": "src/index.js",
"module": "src/index.js",
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-terser": "^0.4.0",
"@rollup/plugin-typescript": "^11.0.0",
"rollup": "^3.19.1",
"@rollup/plugin-terser": "^0.4.3",
"@rollup/plugin-typescript": "11.0.0",
"rollup": "^3.23.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"typescript": "^4.9.4"
}