Skip to content

Commit ce243ef

Browse files
authored
[Web] Map event coordinates through the view's full transform (#4566)
## Description Follow-up to #4531. On web the relative `x`/`y` of an event were computed as `(clientX - rect.left) / scaleX`, which only holds for a positive, axis-aligned scale on the view itself. A mirrored view (`scaleX: -1`) reported negative coordinates, a rotated one divided by zero, and transforms on ancestors (a zoomed container) were ignored entirely. Native reports coordinates in the view's own space through the whole hierarchy, so web disagreed with it in all of those cases. This replaces `calculateViewScale` with `viewportToLocal`, which inverts the linear part of the view's transform composed with its ancestors' around the center of the bounding rect. The center of the rect is where the view's own center lands under any affine transform, so no transform-origin or layout position is needed. The untransformed size comes from `offsetWidth`/`offsetHeight`, or is solved back from the bounds for SVG elements which have neither. All four places that mapped coordinates now share it: `PointerEventManager`, `WheelEventManager`, the hover payload of `GestureHandlerButton.web` and `GestureHandlerWebDelegate.absoluteToLocal` (which had its own `DOMMatrix` version for the pinch focal point and rotation anchor). The helper also steps into the `display: contents` wrapper the v3 detectors attach to, so v3 gets the transform of the child the same way the delegate already did. Reported coordinates change for views with a transformed ancestor, which previously received visual pixels. Perspective is not handled. ### Comparison | Before | After | |---------|--------| | https://github.com/user-attachments/assets/c66772e4-8760-47c2-98c5-36dd6c9ce8de | https://github.com/user-attachments/assets/9d8916a3-3970-4114-a79a-2fcbf3ce2729 | ## Test plan - `yarn test src/web` in the package, new `utils.test.ts` covers scale, mirror, 90/180 degree rotation, the `scale` property, `matrix3d`, SVG, nested and rotated ancestors and the contents wrapper. - Test screen with a box per code path (pointer pan, trackpad pan, hover gesture, Touchable hover, pinch focal, rotation anchor, v2 pan, SVG pan), each transformed or inside a transformed parent, with a dot placed at the reported `x`/`y`. The dot sits under the pointer in every box on web, as it does on native. <details> <summary>Tested on the following code:</summary> ```tsx import React from 'react'; import type { ViewStyle } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector, Touchable, useHoverGesture, usePanGesture, usePinchGesture, useRotationGesture, } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; import Svg, { Rect } from 'react-native-svg'; // Every box is transformed (or sits inside a transformed parent) and the red // dot is placed at the coordinates the gesture reports, that is in the box's // own coordinate space. When the coordinates are mapped correctly the dot sits // under the pointer in every box, on every platform. Each section exercises a // different code path of the web coordinate mapping. const DOT = 16; const MIRROR: ViewStyle['transform'] = [{ scaleX: -1 }]; function useDot() { const x = useSharedValue(-DOT); const y = useSharedValue(-DOT); const style = useAnimatedStyle(() => ({ transform: [ { translateX: x.value - DOT / 2 }, { translateY: y.value - DOT / 2 }, ], })); return { x, y, style }; } type BoxProps = { ref?: React.Ref<React.ComponentRef<typeof View>>; label: string; note?: string; transform?: ViewStyle['transform']; dotStyle: ReturnType<typeof useDot>['style']; children?: React.ReactNode; }; function Box({ ref, label, note, transform, dotStyle, children }: BoxProps) { return ( <View ref={ref} style={[styles.box, { transform }]}> <Text style={styles.label}>{label}</Text> {note && <Text style={styles.note}>{note}</Text>} {children} <Animated.View style={[styles.dot, dotStyle]} /> </View> ); } function Section({ title, children, }: { title: string; children: React.ReactNode; }) { return ( <View style={styles.section}> <Text style={styles.title}>{title}</Text> <View style={styles.row}>{children}</View> </View> ); } // Pointer path: PointerEventManager. const PAN_BOXES: { label: string; transform?: ViewStyle['transform']; parentTransform?: ViewStyle['transform']; }[] = [ { label: 'plain' }, { label: 'scale 1.5', transform: [{ scale: 1.5 }] }, { label: 'scaleX -1', transform: MIRROR }, { label: 'rotate 90', transform: [{ rotate: '90deg' }] }, { label: 'rotate 180', transform: [{ rotate: '180deg' }] }, { label: 'rotate 30', transform: [{ rotate: '30deg' }] }, { label: 'parent scale 1.5', parentTransform: [{ scale: 1.5 }] }, { label: 'parent rotate 45, scaleX -1', transform: MIRROR, parentTransform: [{ rotate: '45deg' }], }, ]; function PanProbe({ label, transform, parentTransform, }: (typeof PAN_BOXES)[number]) { const dot = useDot(); const pan = usePanGesture({ minDistance: 0, onBegin: (event) => { dot.x.value = event.x; dot.y.value = event.y; }, onUpdate: (event) => { dot.x.value = event.x; dot.y.value = event.y; }, }); return ( <View style={{ transform: parentTransform }}> <GestureDetector gesture={pan}> <Box label={label} transform={transform} dotStyle={dot.style} /> </GestureDetector> </View> ); } // Wheel path: WheelEventManager. The pointer does not move, the manager // accumulates the scroll deltas from where the cursor sits. function TrackpadProbe() { const dot = useDot(); const pan = usePanGesture({ enableTrackpadTwoFingerGesture: true, onBegin: (event) => { dot.x.value = event.x; dot.y.value = event.y; }, onUpdate: (event) => { dot.x.value = event.x; dot.y.value = event.y; }, }); return ( <GestureDetector gesture={pan}> <Box label="trackpad pan, scaleX -1" note="two-finger swipe, the dot starts under the cursor and follows the swipe" transform={MIRROR} dotStyle={dot.style} /> </GestureDetector> ); } // Pointer path for hover events. function HoverProbe() { const dot = useDot(); const hover = useHoverGesture({ onBegin: (event) => { dot.x.value = event.x; dot.y.value = event.y; }, onUpdate: (event) => { dot.x.value = event.x; dot.y.value = event.y; }, }); return ( <GestureDetector gesture={hover}> <Box label="hover, scaleX -1" note="mouse, no press" transform={MIRROR} dotStyle={dot.style} /> </GestureDetector> ); } // Button path: GestureHandlerButton.web hover payload. function TouchableHoverProbe() { const dot = useDot(); return ( <Touchable style={[styles.box, { transform: MIRROR }]} onHoverIn={(event) => { dot.x.value = event.x; dot.y.value = event.y; }}> <Text style={styles.label}>Touchable hover, scaleX -1</Text> <Text style={styles.note}> mouse, the dot marks where the cursor entered the box </Text> <Animated.View style={[styles.dot, dot.style]} /> </Touchable> ); } // Delegate path: absoluteToLocal for the Pinch focal point and the Rotation // anchor. function PinchProbe() { const dot = useDot(); const pinch = usePinchGesture({ onUpdate: (event) => { dot.x.value = event.focalX; dot.y.value = event.focalY; }, }); return ( <GestureDetector gesture={pinch}> <Box label="pinch focal, scaleX -1" note="two fingers, the dot sits between them" transform={MIRROR} dotStyle={dot.style} /> </GestureDetector> ); } function RotationProbe() { const dot = useDot(); const rotation = useRotationGesture({ onUpdate: (event) => { dot.x.value = event.anchorX; dot.y.value = event.anchorY; }, }); return ( <GestureDetector gesture={rotation}> <Box label="rotation anchor, rotate 90" note="two fingers, the dot sits between them" transform={[{ rotate: '90deg' }]} dotStyle={dot.style} /> </GestureDetector> ); } // Legacy API: the handler attaches to the box element itself, not to a // wrapper. function LegacyPanProbe() { const dot = useDot(); const pan = Gesture.Pan() .minDistance(0) .onBegin((event) => { dot.x.value = event.x; dot.y.value = event.y; }) .onUpdate((event) => { dot.x.value = event.x; dot.y.value = event.y; }); return ( <GestureDetector gesture={pan}> <Box label="v2 pan, scaleX -1" transform={MIRROR} dotStyle={dot.style} /> </GestureDetector> ); } // SVG elements have no offset size, the mapping recovers it from the bounds. // The mirror sits on the wrapper so the svg itself stays untransformed. function SvgProbe() { const dot = useDot(); const pan = Gesture.Pan() .minDistance(0) .onBegin((event) => { dot.x.value = event.x; dot.y.value = event.y; }) .onUpdate((event) => { dot.x.value = event.x; dot.y.value = event.y; }); return ( <View style={[styles.box, { transform: MIRROR }]}> <GestureDetector gesture={pan}> <Svg width={BOX} height={BOX}> <Rect x="0" y="0" width={BOX} height={BOX} fill="#001a72" /> <Rect x="0" y="0" width={BOX / 2} height={BOX / 2} fill="#1a3a9a" /> </Svg> </GestureDetector> <Text style={[styles.label, styles.overlay]}> svg pan, parent scaleX -1 </Text> <Animated.View style={[styles.dot, dot.style]} /> </View> ); } export default function EmptyExample() { return ( <ScrollView contentContainerStyle={styles.container}> <Section title="Pan, pointer or touch"> {PAN_BOXES.map((box) => ( <PanProbe key={box.label} {...box} /> ))} </Section> <Section title="Other paths"> <TrackpadProbe /> <HoverProbe /> <TouchableHoverProbe /> <PinchProbe /> <RotationProbe /> <LegacyPanProbe /> <SvgProbe /> </Section> </ScrollView> ); } const BOX = 120; const styles = StyleSheet.create({ container: { padding: 16, }, section: { marginBottom: 24, }, title: { fontSize: 18, fontWeight: '600', marginBottom: 8, }, row: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', }, box: { width: BOX, height: BOX, margin: 40, backgroundColor: '#001a72', }, overlay: { position: 'absolute', left: 0, top: 0, }, label: { color: 'white', margin: 8, }, note: { color: '#c9d1ff', fontSize: 10, marginHorizontal: 8, }, dot: { position: 'absolute', left: 0, top: 0, width: DOT, height: DOT, borderRadius: DOT / 2, backgroundColor: '#ff6259', }, }); ``` </details>
1 parent ab44f1e commit ce243ef

10 files changed

Lines changed: 557 additions & 99 deletions

File tree

‎packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx‎

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import { NativeGestureRole } from '../web/interfaces';
1313
import { ButtonEventName } from '../web/tools/ButtonEvents';
1414
import { GestureLifecycleEvent } from '../web/tools/GestureLifecycleEvents';
1515
import {
16-
calculateViewScale,
17-
getEffectiveBoundingRect,
16+
isPointerInBounds,
1817
PointerTypeMapping,
18+
viewportToLocal,
1919
} from '../web/utils';
2020

2121
const prefersReducedMotion = (): boolean =>
@@ -31,25 +31,21 @@ type ButtonPointerEvent = NativeSyntheticEvent<{
3131
}>;
3232

3333
// Same coordinate basis as the press path (see PointerEventManager's
34-
// `mapEvent`), so the two payloads agree. Both reads below force a layout flush,
34+
// `mapEvent`), so the two payloads agree. The reads below force a layout flush,
3535
// hence the caller-side gate on a hover callback existing.
3636
const buttonEventFromPointerEvent = (
3737
event: ButtonPointerEvent
3838
): ButtonEvent => {
3939
const view = event.currentTarget as unknown as HTMLElement;
40-
const rect = getEffectiveBoundingRect(view);
41-
const { scaleX, scaleY } = calculateViewScale(view);
4240
const absoluteX = event.nativeEvent.clientX ?? 0;
4341
const absoluteY = event.nativeEvent.clientY ?? 0;
42+
const absolute = { x: absoluteX, y: absoluteY };
43+
const local = viewportToLocal(view, absolute);
4444

4545
return {
46-
pointerInside:
47-
absoluteX >= rect.left &&
48-
absoluteX <= rect.right &&
49-
absoluteY >= rect.top &&
50-
absoluteY <= rect.bottom,
51-
x: (absoluteX - rect.left) / scaleX,
52-
y: (absoluteY - rect.top) / scaleY,
46+
pointerInside: isPointerInBounds(view, absolute),
47+
x: local.x,
48+
y: local.y,
5349
absoluteX,
5450
absoluteY,
5551
numberOfPointers: 1,
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { viewportToLocal } from '../utils';
2+
3+
type Matrix = [number, number, number, number];
4+
5+
type FakeViewOptions = {
6+
matrix?: Matrix;
7+
transform?: string;
8+
scale?: string;
9+
svg?: boolean;
10+
// An svg root has a client box even though it has no offset size.
11+
svgRoot?: boolean;
12+
// Transforms of the ancestors, outermost first.
13+
ancestors?: Matrix[];
14+
parentDisplay?: string;
15+
};
16+
17+
function multiply([a1, b1, c1, d1]: Matrix, [a2, b2, c2, d2]: Matrix): Matrix {
18+
return [
19+
a1 * a2 + c1 * b2,
20+
b1 * a2 + d1 * b2,
21+
a1 * c2 + c1 * d2,
22+
b1 * c2 + d1 * d2,
23+
];
24+
}
25+
26+
function fakeAncestor(
27+
matrix: Matrix,
28+
parentElement: unknown,
29+
display = 'block'
30+
) {
31+
return {
32+
parentElement,
33+
computedStyle: {
34+
display,
35+
scale: 'none',
36+
transform: `matrix(${matrix.join(', ')}, 0, 0)`,
37+
},
38+
};
39+
}
40+
41+
// A 100x50 view laid out at (200, 100), transformed around its center by its
42+
// own transform and those of its ancestors. The bounding rect is the one the
43+
// browser reports for the transformed box.
44+
function fakeView({
45+
matrix = [1, 0, 0, 1],
46+
transform,
47+
scale = 'none',
48+
svg = false,
49+
svgRoot = false,
50+
ancestors = [],
51+
parentDisplay,
52+
}: FakeViewOptions = {}) {
53+
const width = 100;
54+
const height = 50;
55+
const centerX = 250;
56+
const centerY = 125;
57+
58+
let [a, b, c, d] = matrix;
59+
60+
if (scale !== 'none') {
61+
const [scaleX, scaleY = scaleX] = scale.split(' ').map(parseFloat);
62+
[a, b, c, d] = [a * scaleX, b * scaleY, c * scaleX, d * scaleY];
63+
}
64+
65+
let parentElement: unknown = null;
66+
67+
for (const ancestor of ancestors) {
68+
parentElement = fakeAncestor(ancestor, parentElement, parentDisplay);
69+
}
70+
71+
if (parentDisplay !== 'contents') {
72+
// The innermost ancestor applies first.
73+
for (const ancestor of [...ancestors].reverse()) {
74+
[a, b, c, d] = multiply(ancestor, [a, b, c, d]);
75+
}
76+
}
77+
78+
const rectWidth = Math.abs(a) * width + Math.abs(c) * height;
79+
const rectHeight = Math.abs(b) * width + Math.abs(d) * height;
80+
81+
return {
82+
style: {},
83+
children: [],
84+
parentElement,
85+
// SVG elements have no offset size.
86+
...(svg ? {} : { offsetWidth: width, offsetHeight: height }),
87+
...(svgRoot ? { clientWidth: width, clientHeight: height } : {}),
88+
computedStyle: {
89+
display: 'block',
90+
scale,
91+
transform: transform ?? `matrix(${matrix.join(', ')}, 0, 0)`,
92+
},
93+
getBoundingClientRect: () => ({
94+
left: centerX - rectWidth / 2,
95+
top: centerY - rectHeight / 2,
96+
right: centerX + rectWidth / 2,
97+
bottom: centerY + rectHeight / 2,
98+
width: rectWidth,
99+
height: rectHeight,
100+
}),
101+
} as unknown as HTMLElement;
102+
}
103+
104+
// The Jest environment is node, stub the DOM bits the helper touches.
105+
beforeAll(() => {
106+
(globalThis as Record<string, unknown>).getComputedStyle = (
107+
view: ReturnType<typeof fakeView>
108+
) => (view as unknown as { computedStyle: unknown }).computedStyle;
109+
(globalThis as Record<string, unknown>).DOMRect = function (
110+
left: number,
111+
top: number,
112+
width: number,
113+
height: number
114+
) {
115+
return { left, top, width, height };
116+
};
117+
});
118+
119+
afterAll(() => {
120+
delete (globalThis as Record<string, unknown>).getComputedStyle;
121+
delete (globalThis as Record<string, unknown>).DOMRect;
122+
});
123+
124+
describe('viewportToLocal', () => {
125+
// The point 10px right of and 10px below the view's top-left corner, when
126+
// the view is not transformed.
127+
const point = { x: 210, y: 110 };
128+
129+
test('an untransformed view', () => {
130+
expect(viewportToLocal(fakeView(), point)).toEqual({ x: 10, y: 10 });
131+
expect(viewportToLocal(fakeView({ transform: 'none' }), point)).toEqual({
132+
x: 10,
133+
y: 10,
134+
});
135+
});
136+
137+
test('a scaled view', () => {
138+
// The box grows to 200x100 around its center, the point is 60px right of
139+
// and 35px below the scaled corner.
140+
expect(viewportToLocal(fakeView({ matrix: [2, 0, 0, 2] }), point)).toEqual({
141+
x: 30,
142+
y: 17.5,
143+
});
144+
});
145+
146+
test('a mirrored view', () => {
147+
// The x axis runs from the right edge.
148+
expect(viewportToLocal(fakeView({ matrix: [-1, 0, 0, 1] }), point)).toEqual(
149+
{ x: 90, y: 10 }
150+
);
151+
});
152+
153+
test('a view rotated by 180 degrees', () => {
154+
expect(
155+
viewportToLocal(fakeView({ matrix: [-1, 0, 0, -1] }), point)
156+
).toEqual({ x: 90, y: 40 });
157+
});
158+
159+
test('a view rotated by 90 degrees', () => {
160+
// The 100x50 box turns into a 50x100 one at (225, 75), the view's top-left
161+
// corner lands on the top-right corner of the rotated box, its x axis runs
162+
// downwards and its y axis leftwards.
163+
const view = fakeView({ matrix: [0, 1, -1, 0] });
164+
165+
expect(viewportToLocal(view, { x: 275, y: 75 })).toEqual({ x: 0, y: 0 });
166+
expect(viewportToLocal(view, { x: 265, y: 85 })).toEqual({ x: 10, y: 10 });
167+
});
168+
169+
test('a mirrored and stretched view', () => {
170+
const view = fakeView({ matrix: [-1, 0, 0, 1], scale: '2 1' });
171+
172+
// The box is 200px wide at (150, 100) and the x axis runs from the right
173+
// edge: the point is 60px in, that is 30 view pixels from the right.
174+
expect(viewportToLocal(view, point)).toEqual({ x: 70, y: 10 });
175+
});
176+
177+
test('the scale property', () => {
178+
expect(viewportToLocal(fakeView({ scale: '2' }), point)).toEqual({
179+
x: 30,
180+
y: 17.5,
181+
});
182+
});
183+
184+
test('a 3d transform matrix', () => {
185+
const view = fakeView({
186+
matrix: [2, 0, 0, 2],
187+
transform: 'matrix3d(2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)',
188+
});
189+
190+
expect(viewportToLocal(view, point)).toEqual({ x: 30, y: 17.5 });
191+
});
192+
193+
test('an SVG element', () => {
194+
expect(
195+
viewportToLocal(fakeView({ matrix: [2, 0, 0, 2], svg: true }), point)
196+
).toEqual({ x: 30, y: 17.5 });
197+
expect(
198+
viewportToLocal(fakeView({ matrix: [0, 1, -1, 0], svg: true }), {
199+
x: 265,
200+
y: 85,
201+
})
202+
).toEqual({ x: 10, y: 10 });
203+
});
204+
205+
test('a view inside a scaled ancestor', () => {
206+
// Same bounds as a scaled view, but the view's own space is unscaled.
207+
const view = fakeView({ ancestors: [[2, 0, 0, 2]] });
208+
209+
expect(viewportToLocal(view, point)).toEqual({ x: 30, y: 17.5 });
210+
});
211+
212+
test('a mirrored view inside a rotated ancestor', () => {
213+
// Mirrored, then rotated by 90 degrees: the view's x axis runs upwards and
214+
// its y axis leftwards from the bottom-right corner of the 50x100 box.
215+
const view = fakeView({
216+
matrix: [-1, 0, 0, 1],
217+
ancestors: [[0, 1, -1, 0]],
218+
});
219+
220+
expect(viewportToLocal(view, { x: 275, y: 175 })).toEqual({ x: 0, y: 0 });
221+
expect(viewportToLocal(view, { x: 265, y: 165 })).toEqual({ x: 10, y: 10 });
222+
});
223+
224+
test('a view inside nested scaled ancestors', () => {
225+
// Scaled by 2, then by 0.5 horizontally: the box ends up 100x100.
226+
const view = fakeView({
227+
ancestors: [
228+
[2, 0, 0, 2],
229+
[0.5, 0, 0, 1],
230+
],
231+
});
232+
233+
expect(viewportToLocal(view, point)).toEqual({ x: 10, y: 17.5 });
234+
});
235+
236+
test('a transformed display: contents ancestor has no effect', () => {
237+
const view = fakeView({
238+
ancestors: [[2, 0, 0, 2]],
239+
parentDisplay: 'contents',
240+
});
241+
242+
expect(viewportToLocal(view, point)).toEqual({ x: 10, y: 10 });
243+
});
244+
245+
test('a display: contents wrapper around a transformed view', () => {
246+
const child = fakeView({ matrix: [-1, 0, 0, 1] });
247+
const wrapper = {
248+
style: { display: 'contents' },
249+
children: [child],
250+
childElementCount: 1,
251+
computedStyle: { display: 'contents', scale: 'none', transform: 'none' },
252+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
253+
} as unknown as HTMLElement;
254+
255+
expect(viewportToLocal(wrapper, point)).toEqual({ x: 90, y: 10 });
256+
});
257+
258+
test('a display: contents wrapper around several views', () => {
259+
const wrapper = {
260+
style: { display: 'contents' },
261+
children: [fakeView({ matrix: [-1, 0, 0, 1] }), fakeView()],
262+
childElementCount: 2,
263+
computedStyle: { display: 'contents', scale: 'none', transform: 'none' },
264+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
265+
} as unknown as HTMLElement;
266+
267+
// The point is mapped against the union of the children's bounds.
268+
expect(viewportToLocal(wrapper, point)).toEqual({ x: 10, y: 10 });
269+
});
270+
271+
test('an svg root rotated by 45 degrees', () => {
272+
// The bounds alone cannot tell the sides apart at 45 degrees, the size
273+
// comes from the client box instead.
274+
const s = Math.SQRT1_2;
275+
const view = fakeView({ matrix: [s, s, -s, s], svg: true, svgRoot: true });
276+
const corner = { x: 250 - 50 * s + 25 * s, y: 125 - 50 * s - 25 * s };
277+
278+
const local = viewportToLocal(view, corner);
279+
280+
expect(local.x).toBeCloseTo(0);
281+
expect(local.y).toBeCloseTo(0);
282+
});
283+
284+
test('a display: contents wrapper around several views in a scaled ancestor', () => {
285+
const ancestor = fakeAncestor([2, 0, 0, 2], null);
286+
const wrapper = {
287+
style: { display: 'contents' },
288+
children: [
289+
fakeView({ ancestors: [[2, 0, 0, 2]] }),
290+
fakeView({ ancestors: [[2, 0, 0, 2]] }),
291+
],
292+
childElementCount: 2,
293+
parentElement: ancestor,
294+
// A contents element reports zeros, not its children's size.
295+
offsetWidth: 0,
296+
offsetHeight: 0,
297+
computedStyle: { display: 'contents', scale: 'none', transform: 'none' },
298+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
299+
} as unknown as HTMLElement;
300+
301+
// The union of the children is a scaled 100x50 box.
302+
expect(viewportToLocal(wrapper, point)).toEqual({ x: 30, y: 17.5 });
303+
});
304+
305+
test('a view scaled to zero', () => {
306+
// Nothing to invert, fall back to the bounds.
307+
expect(viewportToLocal(fakeView({ matrix: [0, 0, 0, 0] }), point)).toEqual({
308+
x: -40,
309+
y: -15,
310+
});
311+
});
312+
});

0 commit comments

Comments
 (0)