diff --git a/React/Fabric/RCTSurfaceTouchHandler.mm b/React/Fabric/RCTSurfaceTouchHandler.mm index dc9995dcaaa..bfdc6d9ea16 100644 --- a/React/Fabric/RCTSurfaceTouchHandler.mm +++ b/React/Fabric/RCTSurfaceTouchHandler.mm @@ -174,21 +174,36 @@ static CGFloat RadsToDegrees(CGFloat rads) static int ButtonMaskToButtons(UIEventButtonMask buttonMask) { + int buttonsMaskResult = 0; if (@available(iOS 13.4, *)) { - return (((buttonMask & UIEventButtonMaskPrimary) > 0) ? 1 : 0) | - (((buttonMask & UIEventButtonMaskSecondary) > 0) ? 2 : 0); + if ((buttonMask & UIEventButtonMaskPrimary) != 0) { + buttonsMaskResult |= 1; + } + if ((buttonMask & UIEventButtonMaskSecondary) != 0) { + buttonsMaskResult |= 2; + } + // undocumented mask value which represents the "auxiliary button" (i.e. middle mouse button) + if ((buttonMask & 0x4) != 0) { + buttonsMaskResult |= 4; + } } - return 0; + return buttonsMaskResult; } -static int ButtonMaskToButton(UIEventButtonMask buttonMask) +static int ButtonMaskDiffToButton(UIEventButtonMask prevButtonMask, UIEventButtonMask curButtonMask) { if (@available(iOS 13.4, *)) { - if ((buttonMask & UIEventButtonMaskSecondary) > 0) { + if ((prevButtonMask & UIEventButtonMaskPrimary) != (curButtonMask & UIEventButtonMaskPrimary)) { + return 0; + } + if ((prevButtonMask & 0x4) != (curButtonMask & 0x4)) { + return 1; + } + if ((prevButtonMask & UIEventButtonMaskSecondary) != (curButtonMask & UIEventButtonMaskSecondary)) { return 2; } } - return 0; + return -1; } static void UpdateActiveTouchWithUITouch( @@ -220,9 +235,15 @@ static void UpdateActiveTouchWithUITouch( activeTouch.altitudeAngle = uiTouch.altitudeAngle; activeTouch.azimuthAngle = [uiTouch azimuthAngleInView:nil]; if (@available(iOS 13.4, *)) { - activeTouch.buttonMask = uiEvent.buttonMask; + UIEventButtonMask nextButtonMask = 0; + if (uiTouch.phase != UITouchPhaseEnded) { + nextButtonMask = uiTouch.type == UITouchTypeIndirectPointer ? uiEvent.buttonMask : 1; + } + activeTouch.button = ButtonMaskDiffToButton(activeTouch.buttonMask, nextButtonMask); + activeTouch.buttonMask = nextButtonMask; activeTouch.modifierFlags = uiEvent.modifierFlags; } else { + activeTouch.button = 0; activeTouch.buttonMask = 0; activeTouch.modifierFlags = 0; } @@ -245,7 +266,6 @@ CreateTouchWithUITouch(UITouch *uiTouch, UIEvent *uiEvent, UIView *rootComponent } componentView = componentView.superview; } - UpdateActiveTouchWithUITouch(activeTouch, uiTouch, uiEvent, rootComponentView, rootViewOriginOffset); return activeTouch; } @@ -344,29 +364,11 @@ static PointerEvent CreatePointerEventFromActiveTouch(ActiveTouch activeTouch, R event.detail = 0; - event.button = -1; - if (eventType == RCTTouchEventTypeTouchStart || eventType == RCTTouchEventTypeTouchEnd) { - event.button = activeTouch.button; - } - - event.buttons = 1; - if (@available(iOS 13.4, *)) { - if (activeTouch.touchType == UITouchTypeIndirectPointer) { - // Indirect pointers are the only situations where buttonMask is "accurate" - // so we override the assumed "left click" button value when those type of - // events are recieved - event.buttons = ButtonMaskToButtons(activeTouch.buttonMask); - } - } + event.button = activeTouch.button; + event.buttons = ButtonMaskToButtons(activeTouch.buttonMask); UpdatePointerEventModifierFlags(event, activeTouch.modifierFlags); - // UIEvent's button mask for touch end events still marks the button as down - // so this ensures it's set to 0 as per the pointer event spec - if (eventType == RCTTouchEventTypeTouchEnd) { - event.buttons = 0; - } - event.tangentialPressure = 0.0; event.twist = 0; event.isPrimary = activeTouch.isPrimary; @@ -557,15 +559,12 @@ RCT_NOT_IMPLEMENTED(-(instancetype)initWithTarget : (id)target action : (SEL)act } break; } - - activeTouch.button = ButtonMaskToButton(event.buttonMask); } else { activeTouch.touch.identifier = _identifierPool.dequeue(); if (_primaryTouchPointerId == -1) { _primaryTouchPointerId = activeTouch.touch.identifier; activeTouch.isPrimary = true; } - activeTouch.button = 0; } // If the pointer has not been marked as hovering over views before the touch started, we register diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js index 44184035bb6..834cc7dad66 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/RNTesterPlatformTestTypes.js @@ -40,6 +40,7 @@ export type PlatformTestResult = $ReadOnly<{| export type PlatformTestContext = $ReadOnly<{ assert_true(a: boolean, description: string): void, assert_equals(a: any, b: any, description: string): void, + assert_not_equals(a: any, b: any, description: string): void, assert_greater_than_equal(a: number, b: number, description: string): void, assert_less_than_equal(a: number, b: number, description: string): void, }>; @@ -48,6 +49,7 @@ export type PlatformTestCase = (context: PlatformTestContext) => void; export type AsyncPlatformTest = $ReadOnly<{| done(): void, + step(testcase: PlatformTestCase): void, |}>; export type SyncTestOptions = $ReadOnly<{| diff --git a/packages/rn-tester/js/examples/Experimental/PlatformTest/usePlatformTestHarness.js b/packages/rn-tester/js/examples/Experimental/PlatformTest/usePlatformTestHarness.js index b06f3298201..1fa835e329c 100644 --- a/packages/rn-tester/js/examples/Experimental/PlatformTest/usePlatformTestHarness.js +++ b/packages/rn-tester/js/examples/Experimental/PlatformTest/usePlatformTestHarness.js @@ -35,8 +35,13 @@ function constructAsyncTestHook( $ReadOnly<{[string]: AsyncTestStatus}>, ) => $ReadOnly<{[string]: AsyncTestStatus}>, ) => void, + runTestCase: ( + testCase: PlatformTestCase, + ) => Array, ) { return (description: string, timeoutMs?: number = 10000) => { + const assertionsRef = useRef([]); + const timeoutIDRef = useRef(null); const timeoutHandler = useCallback(() => { @@ -44,6 +49,7 @@ function constructAsyncTestHook( addTestResult({ name: description, assertions: [ + ...assertionsRef.current, { passing: false, name: 'async_timeout', @@ -82,13 +88,16 @@ function constructAsyncTestHook( addTestResult({ name: description, assertions: [ + ...assertionsRef.current, { passing: true, name: 'async_test', description: 'async test should be completed', }, ], - status: 'PASS', + status: didAllAssertionsPass(assertionsRef.current) + ? 'PASS' + : 'FAIL', error: null, }); return {...prev, [description]: 'COMPLETED'}; @@ -97,6 +106,11 @@ function constructAsyncTestHook( }); }, [description]); + const stepHandler = useCallback(testCase => { + const stepAssertions = runTestCase(testCase); + assertionsRef.current.push(...stepAssertions); + }, []); + // test registration useEffect(() => { updateAsyncTestStatuses(prev => { @@ -110,8 +124,9 @@ function constructAsyncTestHook( return useMemo( () => ({ done: completionHandler, + step: stepHandler, }), - [completionHandler], + [completionHandler, stepHandler], ); }; } @@ -174,6 +189,74 @@ export default function usePlatformTestHarness(): PlatformTestHarnessHookResult setTestElementKey(k => k + 1); }, []); + const runTestCase = useCallback((testCase: PlatformTestCase) => { + const assertionResults: Array = []; + + const baseAssert = ( + assertionName: string, + testConditionResult: boolean, + description: string, + failureMessage: string, + ) => { + if (testConditionResult) { + assertionResults.push({ + passing: true, + name: assertionName, + description, + }); + } else { + assertionResults.push({ + passing: false, + name: assertionName, + description, + failureMessage, + }); + } + }; + + const context: PlatformTestContext = { + assert_true: (cond: boolean, desc: string) => + baseAssert( + 'assert_true', + cond, + desc, + "expected 'true' but recieved 'false'", + ), + assert_equals: (a: any, b: any, desc: string) => + baseAssert( + 'assert_equal', + a === b, + desc, + `expected ${a} to equal ${b}`, + ), + assert_not_equals: (a: any, b: any, desc: string) => + baseAssert( + 'assert_not_equals', + a !== b, + desc, + `expected ${a} not to equal ${b}`, + ), + assert_greater_than_equal: (a: number, b: number, desc: string) => + baseAssert( + 'assert_greater_than_equal', + a >= b, + desc, + `expected ${a} to be greater than or equal to ${b}`, + ), + assert_less_than_equal: (a: number, b: number, desc: string) => + baseAssert( + 'assert_less_than_equal', + a <= b, + desc, + `expected ${a} to be less than or equal to ${b}`, + ), + }; + + testCase(context); + + return assertionResults; + }, []); + const testFunction: PlatformTestHarness['test'] = useCallback( ( testCase: PlatformTestCase, @@ -192,63 +275,8 @@ export default function usePlatformTestHarness(): PlatformTestHarnessHookResult return; } - const assertionResults: Array = []; - - const baseAssert = ( - assertionName: string, - testConditionResult: boolean, - description: string, - failureMessage: string, - ) => { - if (testConditionResult) { - assertionResults.push({ - passing: true, - name: assertionName, - description, - }); - } else { - assertionResults.push({ - passing: false, - name: assertionName, - description, - failureMessage, - }); - } - }; - - const context: PlatformTestContext = { - assert_true: (cond: boolean, desc: string) => - baseAssert( - 'assert_true', - cond, - desc, - "expected 'true' but recieved 'false'", - ), - assert_equals: (a: any, b: any, desc: string) => - baseAssert( - 'assert_equal', - a === b, - desc, - `expected ${a} to equal ${b}`, - ), - assert_greater_than_equal: (a: number, b: number, desc: string) => - baseAssert( - 'assert_greater_than_equal', - a >= b, - desc, - `expected ${a} to be greater than or equal to ${b}`, - ), - assert_less_than_equal: (a: number, b: number, desc: string) => - baseAssert( - 'assert_less_than_equal', - a <= b, - desc, - `expected ${a} to be less than or equal to ${b}`, - ), - }; - try { - testCase(context); + const assertionResults = runTestCase(testCase); addTestResult({ name, status: didAllAssertionsPass(assertionResults) ? 'PASS' : 'FAIL', @@ -259,17 +287,22 @@ export default function usePlatformTestHarness(): PlatformTestHarnessHookResult addTestResult({ name, status: 'ERROR', - assertions: assertionResults, + assertions: [], error, }); } }, - [addTestResult], + [addTestResult, runTestCase], ); const asyncTestHook: PlatformTestHarness['useAsyncTest'] = useMemo( - () => constructAsyncTestHook(addTestResult, updateAsyncTestStatuses), - [addTestResult], + () => + constructAsyncTestHook( + addTestResult, + updateAsyncTestStatuses, + runTestCase, + ), + [addTestResult, runTestCase], ); const numPendingAsyncTests = useMemo(() => { diff --git a/packages/rn-tester/js/examples/Experimental/W3CPointerEventPlatformTests/PointerEventPointerMoveOnChordedMouseButton.js b/packages/rn-tester/js/examples/Experimental/W3CPointerEventPlatformTests/PointerEventPointerMoveOnChordedMouseButton.js new file mode 100644 index 00000000000..35bd727a5b2 --- /dev/null +++ b/packages/rn-tester/js/examples/Experimental/W3CPointerEventPlatformTests/PointerEventPointerMoveOnChordedMouseButton.js @@ -0,0 +1,138 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +import type {PlatformTestComponentBaseProps} from '../PlatformTest/RNTesterPlatformTestTypes'; +import type {PointerEvent} from 'react-native/Libraries/Types/CoreEventTypes'; + +import RNTesterPlatformTest from '../PlatformTest/RNTesterPlatformTest'; +import * as React from 'react'; +import {useCallback, useRef} from 'react'; +import {View, StyleSheet} from 'react-native'; + +const styles = StyleSheet.create({ + target: { + backgroundColor: 'black', + padding: 32, + }, +}); + +// adapted from https://github.com/web-platform-tests/wpt/blob/master/pointerevents/pointerevent_pointermove_on_chorded_mouse_button.html +function PointerEventPointerMoveOnChordedMouseButtonTestCase( + props: PlatformTestComponentBaseProps, +) { + const {harness} = props; + + const test_pointermove = harness.useAsyncTest( + 'pointermove events received for button state changes', + ); + + const stepRef = useRef(0); + const firstButtonRef = useRef(0); + + // When a pointer changes button state and the circumstances produce no other pointer event, the pointermove event must be dispatched. + // 5.2.6 + + const handlePointerDown = useCallback( + (event: PointerEvent) => { + test_pointermove.step(({assert_equals}) => { + assert_equals( + stepRef.current, + 0, + 'There must not be more than one pointer down event.', + ); + }); + if (stepRef.current === 0) { + stepRef.current = 1; + firstButtonRef.current = event.nativeEvent.buttons; + } + }, + [test_pointermove], + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + if (stepRef.current === 1 && event.nativeEvent.button !== -1) { + // second button pressed + test_pointermove.step(({assert_not_equals, assert_true}) => { + assert_not_equals( + event.nativeEvent.buttons, + firstButtonRef.current, + 'The pointermove event must be triggered by pressing a second button.', + ); + assert_true( + // eslint-disable-next-line no-bitwise + (event.nativeEvent.buttons & firstButtonRef.current) !== 0, + 'The first button must still be reported pressed.', + ); + }); + stepRef.current = 2; + } else if (stepRef.current === 2 && event.nativeEvent.button !== -1) { + // second button released + test_pointermove.step(({assert_equals}) => { + assert_equals( + event.nativeEvent.buttons, + firstButtonRef.current, + 'The pointermove event must be triggered by releasing the second button.', + ); + }); + stepRef.current = 3; + } + }, + [test_pointermove], + ); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + test_pointermove.step(({assert_equals}) => { + assert_equals( + stepRef.current, + 3, + 'The pointerup event must be triggered after pressing and releasing the second button.', + ); + assert_equals( + event.nativeEvent.buttons, + 0, + 'The pointerup event must be triggered by releasing the last pressed button.', + ); + }); + test_pointermove.done(); + }, + [test_pointermove], + ); + + return ( + + ); +} + +type Props = $ReadOnly<{}>; +export default function PointerEventPointerMoveOnChordedMouseButton( + props: Props, +): React.MixedElement { + return ( + + ); +} diff --git a/packages/rn-tester/js/examples/Experimental/W3CPointerEventsExample.js b/packages/rn-tester/js/examples/Experimental/W3CPointerEventsExample.js index 2a6b6458931..957fb15e797 100644 --- a/packages/rn-tester/js/examples/Experimental/W3CPointerEventsExample.js +++ b/packages/rn-tester/js/examples/Experimental/W3CPointerEventsExample.js @@ -18,6 +18,7 @@ import PointerEventPointerMove from './W3CPointerEventPlatformTests/PointerEvent import CompatibilityAnimatedPointerMove from './Compatibility/CompatibilityAnimatedPointerMove'; import PointerEventPrimaryTouchPointer from './W3CPointerEventPlatformTests/PointerEventPrimaryTouchPointer'; import PointerEventAttributesNoHoverPointers from './W3CPointerEventPlatformTests/PointerEventAttributesNoHoverPointers'; +import PointerEventPointerMoveOnChordedMouseButton from './W3CPointerEventPlatformTests/PointerEventPointerMoveOnChordedMouseButton'; function EventfulView(props: {| name: string, @@ -283,6 +284,14 @@ export default { return ; }, }, + { + name: 'pointerevent_pointermove_on_chorded_mouse_button', + description: '', + title: 'PointerEvents pointermove on button state changes', + render(): React.Node { + return ; + }, + }, CompatibilityAnimatedPointerMove, ], };