From ad0de1781f1ebf32058f34779379f97ae9afb55f Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sun, 20 Sep 2026 16:00:17 +0700 Subject: [PATCH 1/2] [Web] Fix view relative coordinates of trackpad pan events `WheelEventManager` synthesizes a moving pointer from a stationary cursor by accumulating wheel deltas, but only the page coordinates used the accumulator. The view relative ones subtracted a single event's delta, so they moved opposite to the page ones and never advanced across a gesture. --- .../__tests__/PanGestureHandler.test.ts | 44 ++++++++++++++++++- .../src/web/tools/WheelEventManager.ts | 4 +- 2 files changed, 44 insertions(+), 4 deletions(-) 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..53a4d86bf0 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); @@ -95,14 +99,14 @@ class FakeView { this.listeners.get(type)?.delete(listener); } - public wheel(deltaY: number): void { + public wheel(deltaY: number, deltaX = 0): void { this.listeners.get('wheel')?.forEach((listener) => listener({ clientX: 0, clientY: 0, offsetX: 0, offsetY: 0, - deltaX: 0, + deltaX, deltaY, timeStamp: 0, // Not a multiple of 120, so the wheel is recognized as a touchpad. @@ -177,3 +181,39 @@ describe('PanGestureHandler trackpad gesture end', () => { expect(handler.wheelEvents[1].y).toBe(30); }); }); + +describe('PanGestureHandler trackpad coordinates', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + test('the view relative position follows the page position', () => { + const view = new FakeView(); + 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, 5); + view.wheel(10, 5); + view.wheel(10, 5); + + // The delegate places the view at the page origin, so the view relative + // coordinates have to match the page ones. + expect(handler.nativeEvent()).toMatchObject({ + x: 15, + y: 30, + absoluteX: 15, + absoluteY: 30, + }); + }); +}); 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..36a80dd491 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -32,8 +32,8 @@ export default class WheelEventManager extends EventManager { return { x: event.clientX + this.wheelDelta.x, y: event.clientY + this.wheelDelta.y, - offsetX: event.offsetX - event.deltaX, - offsetY: event.offsetY - event.deltaY, + offsetX: event.offsetX + this.wheelDelta.x, + offsetY: event.offsetY + this.wheelDelta.y, pointerId: -1, eventType: EventTypes.MOVE, pointerType: PointerType.OTHER, From 50e6a9bd287a2f97816bb08363bee3813f3c90cc Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sun, 4 Oct 2026 10:07:11 +0700 Subject: [PATCH 2/2] [Web] Use the view's bounding rect for wheel offsets --- .../__tests__/PanGestureHandler.test.ts | 78 ++++++++++++++++--- .../src/web/tools/WheelEventManager.ts | 15 +++- 2 files changed, 79 insertions(+), 14 deletions(-) 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 53a4d86bf0..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 @@ -87,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); @@ -99,23 +118,35 @@ class FakeView { this.listeners.get(type)?.delete(listener); } - public wheel(deltaY: number, deltaX = 0): void { + public wheel(deltaY: number, event: Partial = {}): void { this.listeners.get('wheel')?.forEach((listener) => listener({ clientX: 0, clientY: 0, offsetX: 0, offsetY: 0, - deltaX, + deltaX: 0, deltaY, 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(); @@ -192,8 +223,7 @@ describe('PanGestureHandler trackpad coordinates', () => { jest.useRealTimers(); }); - test('the view relative position follows the page position', () => { - const view = new FakeView(); + function trackpadPan(view: FakeView, event: Partial = {}) { const manager = new WheelEventManager(view as unknown as HTMLElement); const handler = createHandler([manager]); @@ -203,17 +233,45 @@ describe('PanGestureHandler trackpad coordinates', () => { }); handler.attachEventManager(manager); - view.wheel(10, 5); - view.wheel(10, 5); - view.wheel(10, 5); + view.wheel(10, { deltaX: 5, ...event }); + view.wheel(10, { deltaX: 5, ...event }); + view.wheel(10, { deltaX: 5, ...event }); + + return handler.nativeEvent(); + } - // The delegate places the view at the page origin, so the view relative - // coordinates have to match the page ones. - expect(handler.nativeEvent()).toMatchObject({ + 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 36a80dd491..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 + this.wheelDelta.x, - offsetY: event.offsetY + this.wheelDelta.y, + x, + y, + offsetX: (x - rect.left) / scaleX, + offsetY: (y - rect.top) / scaleY, pointerId: -1, eventType: EventTypes.MOVE, pointerType: PointerType.OTHER,