From df839f23094b69b8d9969d4218c1e5d39ec3904c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 5 Oct 2026 12:49:07 +0200 Subject: [PATCH 1/5] Calculate correct view transforms --- .../components/GestureHandlerButton.web.tsx | 20 +-- .../web/tools/GestureHandlerWebDelegate.ts | 37 +---- .../src/web/tools/PointerEventManager.ts | 13 +- .../src/web/tools/WheelEventManager.ts | 10 +- .../src/web/utils.ts | 147 +++++++++++++++--- 5 files changed, 147 insertions(+), 80 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index d2bd195f29..dffce3b7ca 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -13,9 +13,9 @@ import { NativeGestureRole } from '../web/interfaces'; import { ButtonEventName } from '../web/tools/ButtonEvents'; import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents'; import { - calculateViewScale, - getEffectiveBoundingRect, + isPointerInBounds, PointerTypeMapping, + viewportToLocal, } from '../web/utils'; const prefersReducedMotion = (): boolean => @@ -31,25 +31,21 @@ type ButtonPointerEvent = NativeSyntheticEvent<{ }>; // Same coordinate basis as the press path (see PointerEventManager's -// `mapEvent`), so the two payloads agree. Both reads below force a layout flush, +// `mapEvent`), so the two payloads agree. The reads below force a layout flush, // hence the caller-side gate on a hover callback existing. const buttonEventFromPointerEvent = ( event: ButtonPointerEvent ): ButtonEvent => { const view = event.currentTarget as unknown as HTMLElement; - const rect = getEffectiveBoundingRect(view); - const { scaleX, scaleY } = calculateViewScale(view); const absoluteX = event.nativeEvent.clientX ?? 0; const absoluteY = event.nativeEvent.clientY ?? 0; + const absolute = { x: absoluteX, y: absoluteY }; + const local = viewportToLocal(view, absolute); return { - pointerInside: - absoluteX >= rect.left && - absoluteX <= rect.right && - absoluteY >= rect.top && - absoluteY <= rect.bottom, - x: (absoluteX - rect.left) / scaleX, - y: (absoluteY - rect.top) / scaleY, + pointerInside: isPointerInBounds(view, absolute), + x: local.x, + y: local.y, absoluteX, absoluteY, numberOfPointers: 1, diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts index fb74dafdcc..bfff342d3e 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts @@ -7,11 +7,11 @@ import { DEFAULT_TOUCH_ACTION, DEFAULT_USER_SELECT } from '../constants'; import type IGestureHandler from '../handlers/IGestureHandler'; import type { SVGRef } from '../interfaces'; import { - firstNonContentsView, getEffectiveBoundingRect, hasDisplayContents, isPointerInBounds, isRNSVGElement, + viewportToLocal, } from '../utils'; import type EventManager from './EventManager'; import type { @@ -150,40 +150,7 @@ export class GestureHandlerWebDelegate throw new Error(tagMessage('Cannot convert coords on a null view')); } - const localView = this.gestureHandler.usesNativeOrVirtualDetector() - ? firstNonContentsView(this.view) - : this.view; - - const rect = getEffectiveBoundingRect(localView); - const transform = getComputedStyle(localView).transform; - const matrix = - transform && transform !== 'none' - ? new DOMMatrix(transform) - : new DOMMatrix(); - - // Zero out translation — it's already reflected in the bounding rect - // center, so we only need to invert the rotation+scale part. - matrix.e = 0; - matrix.f = 0; - const inverse = matrix.inverse(); - - // Offset from the visual center of the bounding rect - const rectCenterX = rect.left + rect.width / 2; - const rectCenterY = rect.top + rect.height / 2; - const dx = absoluteX - rectCenterX; - const dy = absoluteY - rectCenterY; - - // Apply inverse rotation+scale to get local-space offset from center - const localOffset = inverse.transformPoint(new DOMPoint(dx, dy)); - - // Add back the local center (untransformed dimensions) - const localCenterX = localView.offsetWidth / 2; - const localCenterY = localView.offsetHeight / 2; - - return { - x: localCenterX + localOffset.x, - y: localCenterY + localOffset.y, - }; + return viewportToLocal(this.view, { x: absoluteX, y: absoluteY }); } reset(): void { diff --git a/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts index c8726357a0..7c928914a9 100644 --- a/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts @@ -2,11 +2,10 @@ import { PointerType } from '../../PointerType'; import type { AdaptedEvent, Point } from '../interfaces'; import { EventTypes } from '../interfaces'; import { - calculateViewScale, - getEffectiveBoundingRect, isPointerInBounds, PointerTypeMapping, tryExtractStylusData, + viewportToLocal, } from '../utils'; import EventManager from './EventManager'; @@ -221,14 +220,16 @@ export default class PointerEventManager extends EventManager { } protected mapEvent(event: PointerEvent, eventType: EventTypes): AdaptedEvent { - const rect = getEffectiveBoundingRect(this.view); - const { scaleX, scaleY } = calculateViewScale(this.view); + const local = viewportToLocal(this.view, { + x: event.clientX, + y: event.clientY, + }); return { x: event.clientX, y: event.clientY, - offsetX: (event.clientX - rect.left) / scaleX, - offsetY: (event.clientY - rect.top) / scaleY, + offsetX: local.x, + offsetY: local.y, pointerId: event.pointerId, eventType: eventType, pointerType: 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 8e393b2770..c3cff99e88 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -1,7 +1,7 @@ import { PointerType } from '../../PointerType'; import type { AdaptedEvent } from '../interfaces'; import { EventTypes } from '../interfaces'; -import { calculateViewScale, getEffectiveBoundingRect } from '../utils'; +import { viewportToLocal } from '../utils'; import EventManager from './EventManager'; export default class WheelEventManager extends EventManager { @@ -30,17 +30,15 @@ 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; + const local = viewportToLocal(this.view, { x, y }); return { x, y, - offsetX: (x - rect.left) / scaleX, - offsetY: (y - rect.top) / scaleY, + offsetX: local.x, + offsetY: local.y, pointerId: -1, eventType: EventTypes.MOVE, pointerType: PointerType.OTHER, diff --git a/packages/react-native-gesture-handler/src/web/utils.ts b/packages/react-native-gesture-handler/src/web/utils.ts index 4c77060e91..d14cff2102 100644 --- a/packages/react-native-gesture-handler/src/web/utils.ts +++ b/packages/react-native-gesture-handler/src/web/utils.ts @@ -96,40 +96,145 @@ export const degToRad = (degrees: number) => (degrees * Math.PI) / 180; export const coneToDeviation = (degrees: number) => Math.cos(degToRad(degrees / 2)); -export function calculateViewScale(view: HTMLElement) { - const styles = getComputedStyle(view); +type LinearTransform = [a: number, b: number, c: number, d: number]; - const resultScales = { - scaleX: 1, - scaleY: 1, - }; +const IDENTITY: LinearTransform = [1, 0, 0, 1]; + +function isIdentity([a, b, c, d]: LinearTransform): boolean { + return a === 1 && b === 0 && c === 0 && d === 1; +} + +function getElementLinearTransform(element: Element): LinearTransform { + const styles = getComputedStyle(element); + + if (styles.display === 'contents') { + return IDENTITY; + } + + let [a, b, c, d] = IDENTITY; + + const matrix = /matrix(3d)?\((.+)\)/.exec(styles.transform); + + if (matrix) { + const m = matrix[2].split(',').map(parseFloat); + + [a, b, c, d] = matrix[1] + ? [m[0], m[1], m[4], m[5]] + : [m[0], m[1], m[2], m[3]]; + } - // Get scales from scale property if (styles.scale !== undefined && styles.scale !== 'none') { const scales = styles.scale.split(' '); + const scaleX = parseFloat(scales[0]); + const scaleY = scales[1] ? parseFloat(scales[1]) : scaleX; - if (scales[0]) { - resultScales.scaleX = parseFloat(scales[0]); + [a, b, c, d] = [a * scaleX, b * scaleY, c * scaleX, d * scaleY]; + } + + return [a, b, c, d]; +} + +function multiply( + [a1, b1, c1, d1]: LinearTransform, + [a2, b2, c2, d2]: LinearTransform +): LinearTransform { + return [ + a1 * a2 + c1 * b2, + b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, + b1 * c2 + d1 * d2, + ]; +} + +// View's transform composed with the transforms of its ancestors. +function getViewLinearTransform(view: HTMLElement): LinearTransform | null { + let transform = getElementLinearTransform(view); + let element = view.parentElement; + + while (element) { + const ancestorTransform = getElementLinearTransform(element); + + if (!isIdentity(ancestorTransform)) { + transform = multiply(ancestorTransform, transform); } - resultScales.scaleY = scales[1] - ? parseFloat(scales[1]) - : parseFloat(scales[0]); + element = element.parentElement; + } + + return isIdentity(transform) ? null : transform; +} + +// Size of the view's box before its transform is applied. +function getUntransformedSize( + view: HTMLElement, + rect: DOMRect, + [a, b, c, d]: LinearTransform +) { + if (typeof view.offsetWidth === 'number') { + return { width: view.offsetWidth, height: view.offsetHeight }; } - // Get scales from transform property - const matrixElements = new RegExp(/matrix\((.+)\)/).exec( - styles.transform - )?.[1]; + // SVG elements have no offset size, recover it from the transformed bounds + const det = Math.abs(a * d) - Math.abs(b * c); - if (matrixElements) { - const matrixElementsArray = matrixElements.split(', '); + if (det === 0) { + return { width: rect.width, height: rect.height }; + } + + return { + width: (Math.abs(d) * rect.width - Math.abs(c) * rect.height) / det, + height: (Math.abs(a) * rect.height - Math.abs(b) * rect.width) / det, + }; +} + +// The v3 detectors attach to a `display: contents` wrapper, so the transform +// lives on its child. With several children there is no single transform to +// invert, the wrapper's bounds are used as they are. +function getTransformedView(view: HTMLElement): HTMLElement { + let current = view; - resultScales.scaleX *= parseFloat(matrixElementsArray[0]); - resultScales.scaleY *= parseFloat(matrixElementsArray[3]); + while (hasDisplayContents(current) && current.childElementCount === 1) { + current = current.children[0] as HTMLElement; } - return resultScales; + return current; +} + +// Maps a viewport point to the view's own coordinate space, so that `x` and `y` +// match native under scale, mirroring and rotation of the view or any of its +// ancestors. Perspective is not accounted for. +export function viewportToLocal(view: HTMLElement, point: Point): Point { + const rect = getEffectiveBoundingRect(view); + const transformedView = getTransformedView(view); + const transform = getViewLinearTransform(transformedView); + + if (!transform) { + return { x: point.x - rect.left, y: point.y - rect.top }; + } + + const [a, b, c, d] = transform; + const det = a * d - b * c; + + if (det === 0) { + return { x: point.x - rect.left, y: point.y - rect.top }; + } + + const { width, height } = getUntransformedSize( + transformedView, + rect, + transform + ); + + // The transforms map the center of the view to the center of its bounding + // rect (transform-origin only adds a translation, which the bounding rect + // already reflects), so invert the linear part around the center. + const dx = point.x - (rect.left + rect.width / 2); + const dy = point.y - (rect.top + rect.height / 2); + + return { + x: width / 2 + (d * dx - c * dy) / det, + y: height / 2 + (a * dy - b * dx) / det, + }; } export function tryExtractStylusData( From 4853a37b6757b18b759136d6fc58ad6272724ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 5 Oct 2026 12:52:02 +0200 Subject: [PATCH 2/5] Tests --- .../src/web/__tests__/utils.test.ts | 274 ++++++++++++++++++ .../__tests__/PanGestureHandler.test.ts | 19 +- 2 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts diff --git a/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts b/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts new file mode 100644 index 0000000000..e0a5adfe88 --- /dev/null +++ b/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts @@ -0,0 +1,274 @@ +import { viewportToLocal } from '../utils'; + +type Matrix = [number, number, number, number]; + +type FakeViewOptions = { + matrix?: Matrix; + transform?: string; + scale?: string; + svg?: boolean; + // Transforms of the ancestors, outermost first. + ancestors?: Matrix[]; + parentDisplay?: string; +}; + +function multiply([a1, b1, c1, d1]: Matrix, [a2, b2, c2, d2]: Matrix): Matrix { + return [ + a1 * a2 + c1 * b2, + b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, + b1 * c2 + d1 * d2, + ]; +} + +function fakeAncestor( + matrix: Matrix, + parentElement: unknown, + display = 'block' +) { + return { + parentElement, + computedStyle: { + display, + scale: 'none', + transform: `matrix(${matrix.join(', ')}, 0, 0)`, + }, + }; +} + +// A 100x50 view laid out at (200, 100), transformed around its center by its +// own transform and those of its ancestors. The bounding rect is the one the +// browser reports for the transformed box. +function fakeView({ + matrix = [1, 0, 0, 1], + transform, + scale = 'none', + svg = false, + ancestors = [], + parentDisplay, +}: FakeViewOptions = {}) { + const width = 100; + const height = 50; + const centerX = 250; + const centerY = 125; + + let [a, b, c, d] = matrix; + + if (scale !== 'none') { + const [scaleX, scaleY = scaleX] = scale.split(' ').map(parseFloat); + [a, b, c, d] = [a * scaleX, b * scaleY, c * scaleX, d * scaleY]; + } + + let parentElement: unknown = null; + + for (const ancestor of ancestors) { + parentElement = fakeAncestor(ancestor, parentElement, parentDisplay); + } + + if (parentDisplay !== 'contents') { + // The innermost ancestor applies first. + for (const ancestor of [...ancestors].reverse()) { + [a, b, c, d] = multiply(ancestor, [a, b, c, d]); + } + } + + const rectWidth = Math.abs(a) * width + Math.abs(c) * height; + const rectHeight = Math.abs(b) * width + Math.abs(d) * height; + + return { + style: {}, + children: [], + parentElement, + // SVG elements have no offset size. + ...(svg ? {} : { offsetWidth: width, offsetHeight: height }), + computedStyle: { + display: 'block', + scale, + transform: transform ?? `matrix(${matrix.join(', ')}, 0, 0)`, + }, + getBoundingClientRect: () => ({ + left: centerX - rectWidth / 2, + top: centerY - rectHeight / 2, + right: centerX + rectWidth / 2, + bottom: centerY + rectHeight / 2, + width: rectWidth, + height: rectHeight, + }), + } as unknown as HTMLElement; +} + +// The Jest environment is node, stub the DOM bits the helper touches. +beforeAll(() => { + (globalThis as Record).getComputedStyle = ( + view: ReturnType + ) => (view as unknown as { computedStyle: unknown }).computedStyle; + (globalThis as Record).DOMRect = function ( + left: number, + top: number, + width: number, + height: number + ) { + return { left, top, width, height }; + }; +}); + +afterAll(() => { + delete (globalThis as Record).getComputedStyle; + delete (globalThis as Record).DOMRect; +}); + +describe('viewportToLocal', () => { + // The point 10px right of and 10px below the view's top-left corner, when + // the view is not transformed. + const point = { x: 210, y: 110 }; + + test('an untransformed view', () => { + expect(viewportToLocal(fakeView(), point)).toEqual({ x: 10, y: 10 }); + expect(viewportToLocal(fakeView({ transform: 'none' }), point)).toEqual({ + x: 10, + y: 10, + }); + }); + + test('a scaled view', () => { + // The box grows to 200x100 around its center, the point is 60px right of + // and 35px below the scaled corner. + expect(viewportToLocal(fakeView({ matrix: [2, 0, 0, 2] }), point)).toEqual({ + x: 30, + y: 17.5, + }); + }); + + test('a mirrored view', () => { + // The x axis runs from the right edge. + expect(viewportToLocal(fakeView({ matrix: [-1, 0, 0, 1] }), point)).toEqual( + { x: 90, y: 10 } + ); + }); + + test('a view rotated by 180 degrees', () => { + expect( + viewportToLocal(fakeView({ matrix: [-1, 0, 0, -1] }), point) + ).toEqual({ x: 90, y: 40 }); + }); + + test('a view rotated by 90 degrees', () => { + // The 100x50 box turns into a 50x100 one at (225, 75), the view's top-left + // corner lands on the top-right corner of the rotated box, its x axis runs + // downwards and its y axis leftwards. + const view = fakeView({ matrix: [0, 1, -1, 0] }); + + expect(viewportToLocal(view, { x: 275, y: 75 })).toEqual({ x: 0, y: 0 }); + expect(viewportToLocal(view, { x: 265, y: 85 })).toEqual({ x: 10, y: 10 }); + }); + + test('a mirrored and stretched view', () => { + const view = fakeView({ matrix: [-1, 0, 0, 1], scale: '2 1' }); + + // The box is 200px wide at (150, 100) and the x axis runs from the right + // edge: the point is 60px in, that is 30 view pixels from the right. + expect(viewportToLocal(view, point)).toEqual({ x: 70, y: 10 }); + }); + + test('the scale property', () => { + expect(viewportToLocal(fakeView({ scale: '2' }), point)).toEqual({ + x: 30, + y: 17.5, + }); + }); + + test('a 3d transform matrix', () => { + const view = fakeView({ + matrix: [2, 0, 0, 2], + transform: 'matrix3d(2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)', + }); + + expect(viewportToLocal(view, point)).toEqual({ x: 30, y: 17.5 }); + }); + + test('an SVG element', () => { + expect( + viewportToLocal(fakeView({ matrix: [2, 0, 0, 2], svg: true }), point) + ).toEqual({ x: 30, y: 17.5 }); + expect( + viewportToLocal(fakeView({ matrix: [0, 1, -1, 0], svg: true }), { + x: 265, + y: 85, + }) + ).toEqual({ x: 10, y: 10 }); + }); + + test('a view inside a scaled ancestor', () => { + // Same bounds as a scaled view, but the view's own space is unscaled. + const view = fakeView({ ancestors: [[2, 0, 0, 2]] }); + + expect(viewportToLocal(view, point)).toEqual({ x: 30, y: 17.5 }); + }); + + test('a mirrored view inside a rotated ancestor', () => { + // Mirrored, then rotated by 90 degrees: the view's x axis runs upwards and + // its y axis leftwards from the bottom-right corner of the 50x100 box. + const view = fakeView({ + matrix: [-1, 0, 0, 1], + ancestors: [[0, 1, -1, 0]], + }); + + expect(viewportToLocal(view, { x: 275, y: 175 })).toEqual({ x: 0, y: 0 }); + expect(viewportToLocal(view, { x: 265, y: 165 })).toEqual({ x: 10, y: 10 }); + }); + + test('a view inside nested scaled ancestors', () => { + // Scaled by 2, then by 0.5 horizontally: the box ends up 100x100. + const view = fakeView({ + ancestors: [ + [2, 0, 0, 2], + [0.5, 0, 0, 1], + ], + }); + + expect(viewportToLocal(view, point)).toEqual({ x: 10, y: 17.5 }); + }); + + test('a transformed display: contents ancestor has no effect', () => { + const view = fakeView({ + ancestors: [[2, 0, 0, 2]], + parentDisplay: 'contents', + }); + + expect(viewportToLocal(view, point)).toEqual({ x: 10, y: 10 }); + }); + + test('a display: contents wrapper around a transformed view', () => { + const child = fakeView({ matrix: [-1, 0, 0, 1] }); + const wrapper = { + style: { display: 'contents' }, + children: [child], + childElementCount: 1, + computedStyle: { display: 'contents', scale: 'none', transform: 'none' }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }), + } as unknown as HTMLElement; + + expect(viewportToLocal(wrapper, point)).toEqual({ x: 90, y: 10 }); + }); + + test('a display: contents wrapper around several views', () => { + const wrapper = { + style: { display: 'contents' }, + children: [fakeView({ matrix: [-1, 0, 0, 1] }), fakeView()], + childElementCount: 2, + computedStyle: { display: 'contents', scale: 'none', transform: 'none' }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }), + } as unknown as HTMLElement; + + // The point is mapped against the union of the children's bounds. + expect(viewportToLocal(wrapper, point)).toEqual({ x: 10, y: 10 }); + }); + + test('a view scaled to zero', () => { + // Nothing to invert, fall back to the bounds. + expect(viewportToLocal(fakeView({ matrix: [0, 0, 0, 0] }), point)).toEqual({ + x: -40, + y: -15, + }); + }); +}); 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 bf86decbc4..f814328be6 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 @@ -86,16 +86,20 @@ afterEach(() => { ).gestureHandlers = []; }); +// A 100x100 view scaled around its center, `left` and `top` are the bounds of +// the scaled box. class FakeView { public readonly style = {}; public readonly children = []; + public readonly offsetWidth = 100; + public readonly offsetHeight = 100; public readonly computedStyle: Record; private readonly listeners = new Map void>>(); constructor( private readonly left = 0, private readonly top = 0, - scale = 1 + private readonly scale = 1 ) { this.computedStyle = { display: 'block', @@ -105,7 +109,9 @@ class FakeView { } public getBoundingClientRect() { - return { left: this.left, top: this.top }; + const size = 100 * Math.abs(this.scale); + + return { left: this.left, top: this.top, width: size, height: size }; } public addEventListener(type: string, listener: (event: unknown) => void) { @@ -274,4 +280,13 @@ describe('PanGestureHandler trackpad coordinates', () => { trackpadPan(view, { clientX: 50, clientY: 80, offsetX: 5, offsetY: 10 }) ).toMatchObject({ x: 12.5, y: 25, absoluteX: 65, absoluteY: 110 }); }); + + test('a mirrored view', () => { + const view = new FakeView(40, 60, -1); + + // The view's x axis runs from its right edge: 100 - (65 - 40). + expect( + trackpadPan(view, { clientX: 50, clientY: 80, offsetX: 5, offsetY: 10 }) + ).toMatchObject({ x: 75, y: 50, absoluteX: 65, absoluteY: 110 }); + }); }); From ac868fdefd7bf60d7f996b229f432014a3bd18f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 5 Oct 2026 14:07:29 +0200 Subject: [PATCH 3/5] Review fixes --- .../src/web/__tests__/utils.test.ts | 38 +++++++++++++++++++ .../src/web/utils.ts | 16 ++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts b/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts index e0a5adfe88..7b79fddddb 100644 --- a/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts +++ b/packages/react-native-gesture-handler/src/web/__tests__/utils.test.ts @@ -7,6 +7,8 @@ type FakeViewOptions = { transform?: string; scale?: string; svg?: boolean; + // An svg root has a client box even though it has no offset size. + svgRoot?: boolean; // Transforms of the ancestors, outermost first. ancestors?: Matrix[]; parentDisplay?: string; @@ -44,6 +46,7 @@ function fakeView({ transform, scale = 'none', svg = false, + svgRoot = false, ancestors = [], parentDisplay, }: FakeViewOptions = {}) { @@ -81,6 +84,7 @@ function fakeView({ parentElement, // SVG elements have no offset size. ...(svg ? {} : { offsetWidth: width, offsetHeight: height }), + ...(svgRoot ? { clientWidth: width, clientHeight: height } : {}), computedStyle: { display: 'block', scale, @@ -264,6 +268,40 @@ describe('viewportToLocal', () => { expect(viewportToLocal(wrapper, point)).toEqual({ x: 10, y: 10 }); }); + test('an svg root rotated by 45 degrees', () => { + // The bounds alone cannot tell the sides apart at 45 degrees, the size + // comes from the client box instead. + const s = Math.SQRT1_2; + const view = fakeView({ matrix: [s, s, -s, s], svg: true, svgRoot: true }); + const corner = { x: 250 - 50 * s + 25 * s, y: 125 - 50 * s - 25 * s }; + + const local = viewportToLocal(view, corner); + + expect(local.x).toBeCloseTo(0); + expect(local.y).toBeCloseTo(0); + }); + + test('a display: contents wrapper around several views in a scaled ancestor', () => { + const ancestor = fakeAncestor([2, 0, 0, 2], null); + const wrapper = { + style: { display: 'contents' }, + children: [ + fakeView({ ancestors: [[2, 0, 0, 2]] }), + fakeView({ ancestors: [[2, 0, 0, 2]] }), + ], + childElementCount: 2, + parentElement: ancestor, + // A contents element reports zeros, not its children's size. + offsetWidth: 0, + offsetHeight: 0, + computedStyle: { display: 'contents', scale: 'none', transform: 'none' }, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }), + } as unknown as HTMLElement; + + // The union of the children is a scaled 100x50 box. + expect(viewportToLocal(wrapper, point)).toEqual({ x: 30, y: 17.5 }); + }); + test('a view scaled to zero', () => { // Nothing to invert, fall back to the bounds. expect(viewportToLocal(fakeView({ matrix: [0, 0, 0, 0] }), point)).toEqual({ diff --git a/packages/react-native-gesture-handler/src/web/utils.ts b/packages/react-native-gesture-handler/src/web/utils.ts index d14cff2102..8f7cbbfd6e 100644 --- a/packages/react-native-gesture-handler/src/web/utils.ts +++ b/packages/react-native-gesture-handler/src/web/utils.ts @@ -170,11 +170,21 @@ function getUntransformedSize( rect: DOMRect, [a, b, c, d]: LinearTransform ) { - if (typeof view.offsetWidth === 'number') { - return { width: view.offsetWidth, height: view.offsetHeight }; + // Layout sizes are unaffected by transforms. A `display: contents` element + // has no box and reports zeros. + if (!hasDisplayContents(view)) { + if (typeof view.offsetWidth === 'number') { + return { width: view.offsetWidth, height: view.offsetHeight }; + } + + // An svg root has a client box, the elements inside it do not. + if (view.clientWidth > 0 || view.clientHeight > 0) { + return { width: view.clientWidth, height: view.clientHeight }; + } } - // SVG elements have no offset size, recover it from the transformed bounds + // Recover the size from the transformed bounds: + // rect.width = |a| * width + |c| * height, rect.height = |b| * width + |d| * height. const det = Math.abs(a * d) - Math.abs(b * c); if (det === 0) { From 90874da17fdb084814d583d478108a559dfa95d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 6 Oct 2026 08:20:02 +0200 Subject: [PATCH 4/5] KeyboradEventManager --- .../src/web/tools/KeyboardEventManager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts index 802c4b6e84..fc851a531b 100644 --- a/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts @@ -1,6 +1,7 @@ import { PointerType } from '../../PointerType'; import type { AdaptedEvent } from '../interfaces'; import { EventTypes } from '../interfaces'; +import { viewportToLocal } from '../utils'; import EventManager from './EventManager'; export default class KeyboardEventManager extends EventManager { @@ -112,15 +113,14 @@ export default class KeyboardEventManager extends EventManager { ): AdaptedEvent { const viewRect = (event.target as HTMLElement).getBoundingClientRect(); + // The center of the focused element, which is where its own center lands + // whatever the transforms. const viewportPosition = { x: viewRect?.x + viewRect?.width / 2, y: viewRect?.y + viewRect?.height / 2, }; - const relativePosition = { - x: viewRect?.width / 2, - y: viewRect?.height / 2, - }; + const relativePosition = viewportToLocal(this.view, viewportPosition); return { x: viewportPosition.x, From 74dc8de480b214220d6588837fcfa2a9615f6481 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Tue, 6 Oct 2026 08:56:50 +0200 Subject: [PATCH 5/5] touch events --- .../src/web/handlers/GestureHandler.ts | 23 ++++---- .../handlers/__tests__/GestureHandler.test.ts | 57 +++++++++++++++++++ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index 2ef2d21cdd..f3b0b2e64c 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -579,8 +579,6 @@ export default abstract class GestureHandler implements IGestureHandler { private transformTouchEvent( event: AdaptedEvent ): ResultEvent | undefined { - const rect = this.delegate.measureView(); - const all: PointerData[] = []; const changed: PointerData[] = []; @@ -599,8 +597,8 @@ export default abstract class GestureHandler implements IGestureHandler { all.push({ id: id, - x: element.abosoluteCoords.x - rect.pageX, - y: element.abosoluteCoords.y - rect.pageY, + x: element.relativeCoords.x, + y: element.relativeCoords.y, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); @@ -611,8 +609,8 @@ export default abstract class GestureHandler implements IGestureHandler { if (event.eventType !== EventTypes.CANCEL) { changed.push({ id: this.tracker.getMappedTouchEventId(event.pointerId), - x: event.x - rect.pageX, - y: event.y - rect.pageY, + x: event.offsetX, + y: event.offsetY, absoluteX: event.x, absoluteY: event.y, }); @@ -622,8 +620,8 @@ export default abstract class GestureHandler implements IGestureHandler { changed.push({ id: id, - x: element.abosoluteCoords.x - rect.pageX, - y: element.abosoluteCoords.y - rect.pageY, + x: element.relativeCoords.x, + y: element.relativeCoords.y, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); @@ -677,7 +675,6 @@ export default abstract class GestureHandler implements IGestureHandler { private cancelTouches(): void { this.ensurePropsRef(); - const rect = this.delegate.measureView(); const all: PointerData[] = []; const changed: PointerData[] = []; @@ -693,16 +690,16 @@ export default abstract class GestureHandler implements IGestureHandler { all.push({ id: id, - x: element.abosoluteCoords.x - rect.pageX, - y: element.abosoluteCoords.y - rect.pageY, + x: element.relativeCoords.x, + y: element.relativeCoords.y, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); changed.push({ id: id, - x: element.abosoluteCoords.x - rect.pageX, - y: element.abosoluteCoords.y - rect.pageY, + x: element.relativeCoords.x, + y: element.relativeCoords.y, absoluteX: element.abosoluteCoords.x, absoluteY: element.abosoluteCoords.y, }); diff --git a/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts b/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts index f57cc48a6c..dc9e8e693e 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/__tests__/GestureHandler.test.ts @@ -19,6 +19,11 @@ class TestGestureHandler extends GestureHandler { public markAsNativeDetector() { this.actionType = ActionType.NATIVE_DETECTOR; } + + public touch(event: AdaptedEvent) { + this.tracker.addToTracker(event); + this.sendTouchEvent(event); + } } class TestEventManager extends EventManager { @@ -225,3 +230,55 @@ describe('GestureHandlerOrchestrator idle handlers', () => { ); }); }); + +describe('GestureHandler web touch events', () => { + afterEach(() => { + ( + GestureHandlerOrchestrator.instance as unknown as { + gestureHandlers: IGestureHandler[]; + } + ).gestureHandlers = []; + }); + + test('touches carry the view relative coordinates of the pointer', () => { + const delegate = { + init: jest.fn(), + onEnabledChange: jest.fn(), + reset: jest.fn(), + } as unknown as GestureHandlerDelegate; + const onGestureHandlerEvent = jest.fn(); + const handler = new TestGestureHandler(delegate); + + handler.init( + 1, + { current: { onGestureHandlerEvent } } as never, + ActionType.JS_FUNCTION_OLD_API + ); + // The web config type only spells out the default, the runtime reads it. + handler.setGestureConfig({ + enabled: true, + needsPointerData: true, + } as unknown as Config); + + // The event managers map the pointer into the view's own space, the + // touch payload must not undo that with the view's bounds. + handler.touch({ + x: 150, + y: 110, + offsetX: 25, + offsetY: 5, + pointerId: 0, + eventType: EventTypes.DOWN, + pointerType: PointerType.TOUCH, + time: 0, + }); + + const touch = { id: 0, x: 25, y: 5, absoluteX: 150, absoluteY: 110 }; + + expect(onGestureHandlerEvent).toHaveBeenCalledTimes(1); + expect(onGestureHandlerEvent.mock.calls[0][0].nativeEvent).toMatchObject({ + allTouches: [touch], + changedTouches: [touch], + }); + }); +});