diff --git a/examples/vite-app/package.json b/examples/vite-app/package.json
index 3e2e707a..6d7c5987 100644
--- a/examples/vite-app/package.json
+++ b/examples/vite-app/package.json
@@ -21,7 +21,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
- "zustand": "^4.3.1"
+ "zustand": "^4.4.1"
},
"devDependencies": {
"@cypress/skip-test": "^2.6.1",
diff --git a/examples/vite-app/src/examples/Figma/index.tsx b/examples/vite-app/src/examples/Figma/index.tsx
index bccafa96..6780b6cb 100644
--- a/examples/vite-app/src/examples/Figma/index.tsx
+++ b/examples/vite-app/src/examples/Figma/index.tsx
@@ -1,4 +1,4 @@
-import { ReactFlow, Background, BackgroundVariant, Node, Edge, SelectionMode } from '@xyflow/react';
+import { ReactFlow, Background, BackgroundVariant, Node, Edge, SelectionMode, Controls, Panel } from '@xyflow/react';
const MULTI_SELECT_KEY = ['Meta', 'Shift'];
@@ -21,6 +21,10 @@ const onPaneContextMenu = (e: any) => {
const panOnDrag = [1, 2];
+const onMoveStart = (e: any) => console.log('move start', e);
+const onMove = (e: any) => console.log('move', e);
+const onMoveEnd = (e: any) => console.log('move end', e);
+
const BasicFlow = () => {
return (
{
fitView
selectNodesOnDrag={false}
onSelectionContextMenu={onPaneContextMenu}
+ onMoveStart={onMoveStart}
+ onMove={onMove}
+ onMoveEnd={onMoveEnd}
>
-
+
);
};
diff --git a/packages/system/src/xypanzoom/XYPanZoom.ts b/packages/system/src/xypanzoom/XYPanZoom.ts
index 1faded97..f5a3e5fe 100644
--- a/packages/system/src/xypanzoom/XYPanZoom.ts
+++ b/packages/system/src/xypanzoom/XYPanZoom.ts
@@ -26,6 +26,8 @@ export type ZoomPanValues = {
prevViewport: Viewport;
mouseButton: number;
timerId: ReturnType | undefined;
+ panScrollTimeout: ReturnType | undefined;
+ isPanScrolling: boolean;
};
export function XYPanZoom({
@@ -46,6 +48,8 @@ export function XYPanZoom({
prevViewport: { x: 0, y: 0, zoom: 0 },
mouseButton: 0,
timerId: undefined,
+ panScrollTimeout: undefined,
+ isPanScrolling: false,
};
const bbox = domNode.getBoundingClientRect();
const d3ZoomInstance = zoom().scaleExtent([minZoom, maxZoom]).translateExtent(translateExtent);
@@ -98,12 +102,16 @@ export function XYPanZoom({
const wheelHandler = isPanOnScroll
? createPanOnScrollHandler({
+ zoomPanValues,
noWheelClassName,
d3Selection,
d3Zoom: d3ZoomInstance,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
+ onPanZoomStart,
+ onPanZoom,
+ onPanZoomEnd,
})
: createZoomOnScrollHandler({
noWheelClassName,
diff --git a/packages/system/src/xypanzoom/eventhandler.ts b/packages/system/src/xypanzoom/eventhandler.ts
index b2b46b48..456e2332 100644
--- a/packages/system/src/xypanzoom/eventhandler.ts
+++ b/packages/system/src/xypanzoom/eventhandler.ts
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type { D3ZoomEvent } from 'd3-zoom';
import { pointer } from 'd3-selection';
@@ -8,28 +9,24 @@ import {
type D3ZoomHandler,
type D3ZoomInstance,
type OnPanZoom,
- type Viewport,
type OnDraggingChange,
type OnTransformChange,
} from '../types';
import { isRightClickPan, isWrappedWithClass, transformToViewport, viewChanged, wheelDelta } from './utils';
-import { clamp, isMacOs } from '../utils';
-
-export type ZoomPanValues = {
- isZoomingOrPanning: boolean;
- usedRightMouseButton: boolean;
- prevViewport: Viewport;
- mouseButton: number;
- timerId: ReturnType | undefined;
-};
+import { isMacOs } from '../utils';
+import { ZoomPanValues } from './XYPanZoom';
export type PanOnScrollParams = {
+ zoomPanValues: ZoomPanValues;
noWheelClassName: string;
d3Selection: D3SelectionInstance;
d3Zoom: D3ZoomInstance;
panOnScrollMode: PanOnScrollMode;
panOnScrollSpeed: number;
zoomOnPinch: boolean;
+ onPanZoomStart?: OnPanZoom;
+ onPanZoom?: OnPanZoom;
+ onPanZoomEnd?: OnPanZoom;
};
export type ZoomOnScrollParams = {
@@ -62,12 +59,16 @@ export type PanZoomEndParams = {
};
export function createPanOnScrollHandler({
+ zoomPanValues,
noWheelClassName,
d3Selection,
d3Zoom,
panOnScrollMode,
panOnScrollSpeed,
zoomOnPinch,
+ onPanZoomStart,
+ onPanZoom,
+ onPanZoomEnd,
}: PanOnScrollParams) {
return (event: any) => {
if (isWrappedWithClass(event, noWheelClassName)) {
@@ -105,8 +106,33 @@ export function createPanOnScrollHandler({
d3Zoom.translateBy(
d3Selection,
-(deltaX / currentZoom) * panOnScrollSpeed,
- -(deltaY / currentZoom) * panOnScrollSpeed
+ -(deltaY / currentZoom) * panOnScrollSpeed,
+ // @ts-ignore
+ { internal: true }
);
+
+ const nextViewport = transformToViewport(d3Selection.property('__zoom'));
+
+ clearTimeout(zoomPanValues.panScrollTimeout);
+
+ // for pan on scroll we need to handle the event calls on our own
+ // we can't use the start, zoom and end events from d3-zoom
+ // because start and move gets called on every scroll event and not once at the beginning
+ if (!zoomPanValues.isPanScrolling) {
+ zoomPanValues.isPanScrolling = true;
+
+ onPanZoomStart?.(event, nextViewport);
+ }
+
+ if (zoomPanValues.isPanScrolling) {
+ onPanZoom?.(event, nextViewport);
+
+ zoomPanValues.panScrollTimeout = setTimeout(() => {
+ onPanZoomEnd?.(event, nextViewport);
+
+ zoomPanValues.isPanScrolling = false;
+ }, 150);
+ }
};
}
@@ -124,6 +150,10 @@ export function createZoomOnScrollHandler({ noWheelClassName, preventScrolling,
export function createPanZoomStartHandler({ zoomPanValues, onDraggingChange, onPanZoomStart }: PanZoomStartParams) {
return (event: D3ZoomEvent) => {
+ if (event.sourceEvent?.internal) {
+ return;
+ }
+
const viewport = transformToViewport(event.transform);
// we need to remember it here, because it's always 0 in the "zoom" event
@@ -155,7 +185,7 @@ export function createPanZoomHandler({
onTransformChange([event.transform.x, event.transform.y, event.transform.k]);
- if (onPanZoom) {
+ if (onPanZoom && !event.sourceEvent?.internal) {
onPanZoom?.(event.sourceEvent as MouseEvent | TouchEvent, transformToViewport(event.transform));
}
};
@@ -170,6 +200,9 @@ export function createPanZoomEndHandler({
onPaneContextMenu,
}: PanZoomEndParams) {
return (event: D3ZoomEvent) => {
+ if (event.sourceEvent?.internal) {
+ return;
+ }
zoomPanValues.isZoomingOrPanning = false;
if (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b1b4d1c3..d63f897b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -99,8 +99,8 @@ importers:
specifier: ^6.3.0
version: registry.npmjs.org/react-router-dom@6.12.1(react-dom@18.2.0)(react@18.2.0)
zustand:
- specifier: ^4.3.1
- version: registry.npmjs.org/zustand@4.3.8(react@18.2.0)
+ specifier: ^4.4.1
+ version: registry.npmjs.org/zustand@4.4.1(@types/react@18.2.12)(react@18.2.0)
devDependencies:
'@cypress/skip-test':
specifier: ^2.6.1
@@ -2421,7 +2421,7 @@ packages:
'@typescript-eslint/typescript-estree': registry.npmjs.org/@typescript-eslint/typescript-estree@5.60.0(typescript@5.1.3)
eslint: registry.npmjs.org/eslint@8.43.0
eslint-scope: registry.npmjs.org/eslint-scope@5.1.1
- semver: registry.npmjs.org/semver@7.5.1
+ semver: registry.npmjs.org/semver@7.5.4
transitivePeerDependencies:
- supports-color
- typescript
@@ -4140,7 +4140,7 @@ packages:
dependencies:
acorn: registry.npmjs.org/acorn@8.8.2
acorn-jsx: registry.npmjs.org/acorn-jsx@5.3.2(acorn@8.8.2)
- eslint-visitor-keys: registry.npmjs.org/eslint-visitor-keys@3.4.1
+ eslint-visitor-keys: registry.npmjs.org/eslint-visitor-keys@3.4.3
dev: true
registry.npmjs.org/esprima@4.0.1:
@@ -5076,14 +5076,6 @@ packages:
ci-info: registry.npmjs.org/ci-info@3.8.0
dev: true
- registry.npmjs.org/is-core-module@2.12.1:
- resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz}
- name: is-core-module
- version: 2.12.1
- dependencies:
- has: registry.npmjs.org/has@1.0.3
- dev: true
-
registry.npmjs.org/is-core-module@2.13.0:
resolution: {integrity: sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz}
name: is-core-module
@@ -6772,7 +6764,7 @@ packages:
version: 1.22.2
hasBin: true
dependencies:
- is-core-module: registry.npmjs.org/is-core-module@2.12.1
+ is-core-module: registry.npmjs.org/is-core-module@2.13.0
path-parse: registry.npmjs.org/path-parse@1.0.7
supports-preserve-symlinks-flag: registry.npmjs.org/supports-preserve-symlinks-flag@1.0.0
dev: true
@@ -8338,25 +8330,6 @@ packages:
engines: {node: '>=10'}
dev: true
- registry.npmjs.org/zustand@4.3.8(react@18.2.0):
- resolution: {integrity: sha512-4h28KCkHg5ii/wcFFJ5Fp+k1J3gJoasaIbppdgZFO4BPJnsNxL0mQXBSFgOgAdCdBj35aDTPvdAJReTMntFPGg==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/zustand/-/zustand-4.3.8.tgz}
- id: registry.npmjs.org/zustand/4.3.8
- name: zustand
- version: 4.3.8
- engines: {node: '>=12.7.0'}
- peerDependencies:
- immer: '>=9.0'
- react: '>=16.8'
- peerDependenciesMeta:
- immer:
- optional: true
- react:
- optional: true
- dependencies:
- react: registry.npmjs.org/react@18.2.0
- use-sync-external-store: registry.npmjs.org/use-sync-external-store@1.2.0(react@18.2.0)
- dev: false
-
registry.npmjs.org/zustand@4.4.0(@types/react@18.2.12)(react@18.2.0):
resolution: {integrity: sha512-2dq6wq4dSxbiPTamGar0NlIG/av0wpyWZJGeQYtUOLegIUvhM2Bf86ekPlmgpUtS5uR7HyetSiktYrGsdsyZgQ==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/zustand/-/zustand-4.4.0.tgz}
id: registry.npmjs.org/zustand/4.4.0
@@ -8379,3 +8352,26 @@ packages:
react: registry.npmjs.org/react@18.2.0
use-sync-external-store: registry.npmjs.org/use-sync-external-store@1.2.0(react@18.2.0)
dev: false
+
+ registry.npmjs.org/zustand@4.4.1(@types/react@18.2.12)(react@18.2.0):
+ resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.org/zustand/-/zustand-4.4.1.tgz}
+ id: registry.npmjs.org/zustand/4.4.1
+ name: zustand
+ version: 4.4.1
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ dependencies:
+ '@types/react': registry.npmjs.org/@types/react@18.2.12
+ react: registry.npmjs.org/react@18.2.0
+ use-sync-external-store: registry.npmjs.org/use-sync-external-store@1.2.0(react@18.2.0)
+ dev: false