diff --git a/packages/react-native-gesture-handler/src/web/handlers/__tests__/PanGestureHandler.test.ts b/packages/react-native-gesture-handler/src/web/handlers/__tests__/PanGestureHandler.test.ts index ad0f588117..bf86decbc4 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/__tests__/PanGestureHandler.test.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/__tests__/PanGestureHandler.test.ts @@ -17,6 +17,10 @@ class TestPanGestureHandler extends PanGestureHandler { this.onWheel(event); } + public nativeEvent() { + return this.transformNativeEvent(); + } + protected override onWheel(event: AdaptedEvent): void { this.wheelEvents.push(event); super.onWheel(event); @@ -83,8 +87,27 @@ afterEach(() => { }); class FakeView { + public readonly style = {}; + public readonly children = []; + public readonly computedStyle: Record; private readonly listeners = new Map void>>(); + constructor( + private readonly left = 0, + private readonly top = 0, + scale = 1 + ) { + this.computedStyle = { + display: 'block', + scale: 'none', + transform: `matrix(${scale}, 0, 0, ${scale}, 0, 0)`, + }; + } + + public getBoundingClientRect() { + return { left: this.left, top: this.top }; + } + public addEventListener(type: string, listener: (event: unknown) => void) { const listeners = this.listeners.get(type) ?? new Set(); listeners.add(listener); @@ -95,7 +118,7 @@ class FakeView { this.listeners.get(type)?.delete(listener); } - public wheel(deltaY: number): void { + public wheel(deltaY: number, event: Partial = {}): void { this.listeners.get('wheel')?.forEach((listener) => listener({ clientX: 0, @@ -107,11 +130,23 @@ class FakeView { timeStamp: 0, // Not a multiple of 120, so the wheel is recognized as a touchpad. wheelDeltaY: 13, + ...event, }) ); } } +// The Jest environment is node, the view bounds are read through +// getComputedStyle. +beforeAll(() => { + (globalThis as Record).getComputedStyle = (view: FakeView) => + view.computedStyle; +}); + +afterAll(() => { + delete (globalThis as Record).getComputedStyle; +}); + describe('PanGestureHandler config reset', () => { test('a config without enableTrackpadTwoFingerGesture restores the disabled default', () => { const handler = createHandler(); @@ -177,3 +212,66 @@ describe('PanGestureHandler trackpad gesture end', () => { expect(handler.wheelEvents[1].y).toBe(30); }); }); + +describe('PanGestureHandler trackpad coordinates', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + function trackpadPan(view: FakeView, event: Partial = {}) { + const manager = new WheelEventManager(view as unknown as HTMLElement); + const handler = createHandler([manager]); + + handler.setGestureConfig({ + enabled: true, + enableTrackpadTwoFingerGesture: true, + }); + handler.attachEventManager(manager); + + view.wheel(10, { deltaX: 5, ...event }); + view.wheel(10, { deltaX: 5, ...event }); + view.wheel(10, { deltaX: 5, ...event }); + + return handler.nativeEvent(); + } + + test('a view at the page origin', () => { + expect(trackpadPan(new FakeView())).toMatchObject({ + x: 15, + y: 30, + absoluteX: 15, + absoluteY: 30, + }); + }); + + test('a view away from the page origin', () => { + const view = new FakeView(40, 60); + + expect( + trackpadPan(view, { clientX: 50, clientY: 80, offsetX: 10, offsetY: 20 }) + ).toMatchObject({ x: 25, y: 50, absoluteX: 65, absoluteY: 110 }); + }); + + test('a wheel over a child of the view', () => { + const view = new FakeView(40, 60); + + // The child sits at (70, 90), offsetX and offsetY are relative to it. + expect( + trackpadPan(view, { clientX: 80, clientY: 100, offsetX: 10, offsetY: 10 }) + ).toMatchObject({ x: 55, y: 70, absoluteX: 95, absoluteY: 130 }); + }); + + test('a scaled view', () => { + const view = new FakeView(40, 60, 2); + + // offsetX and offsetY are in the untransformed space of the view. + expect( + trackpadPan(view, { clientX: 50, clientY: 80, offsetX: 5, offsetY: 10 }) + ).toMatchObject({ x: 12.5, y: 25, absoluteX: 65, absoluteY: 110 }); + }); +}); diff --git a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts index f405656f0d..8e393b2770 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -1,6 +1,7 @@ import { PointerType } from '../../PointerType'; import type { AdaptedEvent } from '../interfaces'; import { EventTypes } from '../interfaces'; +import { calculateViewScale, getEffectiveBoundingRect } from '../utils'; import EventManager from './EventManager'; export default class WheelEventManager extends EventManager { @@ -29,11 +30,17 @@ export default class WheelEventManager extends EventManager { } protected mapEvent(event: WheelEvent): AdaptedEvent { + const rect = getEffectiveBoundingRect(this.view); + const { scaleX, scaleY } = calculateViewScale(this.view); + + const x = event.clientX + this.wheelDelta.x; + const y = event.clientY + this.wheelDelta.y; + return { - x: event.clientX + this.wheelDelta.x, - y: event.clientY + this.wheelDelta.y, - offsetX: event.offsetX - event.deltaX, - offsetY: event.offsetY - event.deltaY, + x, + y, + offsetX: (x - rect.left) / scaleX, + offsetY: (y - rect.top) / scaleY, pointerId: -1, eventType: EventTypes.MOVE, pointerType: PointerType.OTHER,