/** * 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 */ 'use strict'; var DELAY = 'DELAY'; var ERROR = 'ERROR'; var LONG_PRESS_DETECTED = 'LONG_PRESS_DETECTED'; var NOT_RESPONDER = 'NOT_RESPONDER'; var RESPONDER_ACTIVE_LONG_PRESS_START = 'RESPONDER_ACTIVE_LONG_PRESS_START'; var RESPONDER_ACTIVE_PRESS_START = 'RESPONDER_ACTIVE_PRESS_START'; var RESPONDER_INACTIVE_PRESS_START = 'RESPONDER_INACTIVE_PRESS_START'; var RESPONDER_GRANT = 'RESPONDER_GRANT'; var RESPONDER_RELEASE = 'RESPONDER_RELEASE'; var RESPONDER_TERMINATED = 'RESPONDER_TERMINATED'; var Transitions = Object.freeze({ NOT_RESPONDER: { DELAY: ERROR, RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START, RESPONDER_RELEASE: ERROR, RESPONDER_TERMINATED: ERROR, LONG_PRESS_DETECTED: ERROR }, RESPONDER_INACTIVE_PRESS_START: { DELAY: RESPONDER_ACTIVE_PRESS_START, RESPONDER_GRANT: ERROR, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: ERROR }, RESPONDER_ACTIVE_PRESS_START: { DELAY: ERROR, RESPONDER_GRANT: ERROR, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START }, RESPONDER_ACTIVE_LONG_PRESS_START: { DELAY: ERROR, RESPONDER_GRANT: ERROR, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: RESPONDER_ACTIVE_LONG_PRESS_START }, ERROR: { DELAY: NOT_RESPONDER, RESPONDER_GRANT: RESPONDER_INACTIVE_PRESS_START, RESPONDER_RELEASE: NOT_RESPONDER, RESPONDER_TERMINATED: NOT_RESPONDER, LONG_PRESS_DETECTED: NOT_RESPONDER } }); var isActiveSignal = signal => signal === RESPONDER_ACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_LONG_PRESS_START; var isButtonRole = element => element.getAttribute('role') === 'button'; var isPressStartSignal = signal => signal === RESPONDER_INACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_PRESS_START || signal === RESPONDER_ACTIVE_LONG_PRESS_START; var isTerminalSignal = signal => signal === RESPONDER_TERMINATED || signal === RESPONDER_RELEASE; var isValidKeyPress = event => { var key = event.key, target = event.target; var role = target.getAttribute('role'); var isSpacebar = key === ' ' || key === 'Spacebar'; return key === 'Enter' || isSpacebar && role === 'button'; }; var DEFAULT_LONG_PRESS_DELAY_MS = 450; // 500 - 50 var DEFAULT_PRESS_DELAY_MS = 50; /** * =========================== PressResponder Tutorial =========================== * * The `PressResponder` class helps you create press interactions by analyzing the * geometry of elements and observing when another responder (e.g. ScrollView) * has stolen the touch lock. It offers hooks for your component to provide * interaction feedback to the user: * * - When a press has activated (e.g. highlight an element) * - When a press has deactivated (e.g. un-highlight an element) * - When a press sould trigger an action, meaning it activated and deactivated * while within the geometry of the element without the lock being stolen. * * A high quality interaction isn't as simple as you might think. There should * be a slight delay before activation. Moving your finger beyond an element's * bounds should trigger deactivation, but moving the same finger back within an * element's bounds should trigger reactivation. * * In order to use `PressResponder`, do the following: * * const pressResponder = new PressResponder(config); * * 2. Choose the rendered component who should collect the press events. On that * element, spread `pressability.getEventHandlers()` into its props. * * return ( * * ); * * 3. Reset `PressResponder` when your component unmounts. * * componentWillUnmount() { * this.state.pressResponder.reset(); * } * * ==================== Implementation Details ==================== * * `PressResponder` only assumes that there exists a `HitRect` node. The `PressRect` * is an abstract box that is extended beyond the `HitRect`. * * # Geometry * * ┌────────────────────────┐ * │ ┌──────────────────┐ │ - Presses start anywhere within `HitRect`. * │ │ ┌────────────┐ │ │ * │ │ │ VisualRect │ │ │ * │ │ └────────────┘ │ │ - When pressed down for sufficient amount of time * │ │ HitRect │ │ before letting up, `VisualRect` activates. * │ └──────────────────┘ │ * │ Out Region o │ * └────────────────────│───┘ * └────── When the press is released outside the `HitRect`, * the responder is NOT eligible for a "press". * * # State Machine * * ┌───────────────┐ ◀──── RESPONDER_RELEASE * │ NOT_RESPONDER │ * └───┬───────────┘ ◀──── RESPONDER_TERMINATED * │ * │ RESPONDER_GRANT (HitRect) * │ * ▼ * ┌─────────────────────┐ ┌───────────────────┐ ┌───────────────────┐ * │ RESPONDER_INACTIVE_ │ DELAY │ RESPONDER_ACTIVE_ │ T + DELAY │ RESPONDER_ACTIVE_ │ * │ PRESS_START ├────────▶ │ PRESS_START ├────────────▶ │ LONG_PRESS_START │ * └─────────────────────┘ └───────────────────┘ └───────────────────┘ * * T + DELAY => LONG_PRESS_DELAY + DELAY * * Not drawn are the side effects of each transition. The most important side * effect is the invocation of `onLongPress`. Only when the browser produces a * `click` event is `onPress` invoked. */ export default class PressResponder { constructor(config) { this._eventHandlers = null; this._isPointerTouch = false; this._longPressDelayTimeout = null; this._longPressDispatched = false; this._pressDelayTimeout = null; this._pressOutDelayTimeout = null; this._touchState = NOT_RESPONDER; this.configure(config); } configure(config) { this._config = config; } /** * Resets any pending timers. This should be called on unmount. */ reset() { this._cancelLongPressDelayTimeout(); this._cancelPressDelayTimeout(); this._cancelPressOutDelayTimeout(); } /** * Returns a set of props to spread into the interactive element. */ getEventHandlers() { if (this._eventHandlers == null) { this._eventHandlers = this._createEventHandlers(); } return this._eventHandlers; } _createEventHandlers() { var start = (event, shouldDelay) => { event.persist(); this._cancelPressOutDelayTimeout(); this._longPressDispatched = false; this._selectionTerminated = false; this._touchState = NOT_RESPONDER; this._isPointerTouch = event.nativeEvent.type === 'touchstart'; this._receiveSignal(RESPONDER_GRANT, event); var delayPressStart = normalizeDelay(this._config.delayPressStart, 0, DEFAULT_PRESS_DELAY_MS); if (shouldDelay !== false && delayPressStart > 0) { this._pressDelayTimeout = setTimeout(() => { this._receiveSignal(DELAY, event); }, delayPressStart); } else { this._receiveSignal(DELAY, event); } var delayLongPress = normalizeDelay(this._config.delayLongPress, 10, DEFAULT_LONG_PRESS_DELAY_MS); this._longPressDelayTimeout = setTimeout(() => { this._handleLongPress(event); }, delayLongPress + delayPressStart); }; var end = event => { this._receiveSignal(RESPONDER_RELEASE, event); }; var keyupHandler = event => { var onPress = this._config.onPress; var target = event.target; if (this._touchState !== NOT_RESPONDER && isValidKeyPress(event)) { end(event); document.removeEventListener('keyup', keyupHandler); var role = target.getAttribute('role'); var elementType = target.tagName.toLowerCase(); var isNativeInteractiveElement = role === 'link' || elementType === 'a' || elementType === 'button' || elementType === 'input' || elementType === 'select' || elementType === 'textarea'; if (onPress != null && !isNativeInteractiveElement) { onPress(event); } } }; return { onStartShouldSetResponder: event => { var disabled = this._config.disabled; if (disabled && isButtonRole(event.currentTarget)) { event.stopPropagation(); } if (disabled == null) { return true; } return !disabled; }, onKeyDown: event => { var disabled = this._config.disabled; var key = event.key, target = event.target; if (!disabled && isValidKeyPress(event)) { if (this._touchState === NOT_RESPONDER) { start(event, false); // Listen to 'keyup' on document to account for situations where // focus is moved to another element during 'keydown'. document.addEventListener('keyup', keyupHandler); } var role = target.getAttribute('role'); var isSpacebarKey = key === ' ' || key === 'Spacebar'; var _isButtonRole = role === 'button' || role === 'menuitem'; if (isSpacebarKey && _isButtonRole) { // Prevent spacebar scrolling the window event.preventDefault(); } event.stopPropagation(); } }, onResponderGrant: event => start(event), onResponderMove: event => { if (this._config.onPressMove != null) { this._config.onPressMove(event); } var touch = getTouchFromResponderEvent(event); if (this._touchActivatePosition != null) { var deltaX = this._touchActivatePosition.pageX - touch.pageX; var deltaY = this._touchActivatePosition.pageY - touch.pageY; if (Math.hypot(deltaX, deltaY) > 10) { this._cancelLongPressDelayTimeout(); } } }, onResponderRelease: event => end(event), onResponderTerminate: event => { if (event.nativeEvent.type === 'selectionchange') { this._selectionTerminated = true; } this._receiveSignal(RESPONDER_TERMINATED, event); }, onResponderTerminationRequest: event => { var _this$_config = this._config, cancelable = _this$_config.cancelable, disabled = _this$_config.disabled, onLongPress = _this$_config.onLongPress; // If `onLongPress` is provided, don't terminate on `contextmenu` as default // behavior will be prevented for non-mouse pointers. if (!disabled && onLongPress != null && this._isPointerTouch && event.nativeEvent.type === 'contextmenu') { return false; } if (cancelable == null) { return true; } return cancelable; }, // NOTE: this diverges from react-native in 3 significant ways: // * The `onPress` callback is not connected to the responder system (the native // `click` event must be used but is dispatched in many scenarios where no pointers // are on the screen.) Therefore, it's possible for `onPress` to be called without // `onPress{Start,End}` being called first. // * The `onPress` callback is only be called on the first ancestor of the native // `click` target that is using the PressResponder. // * The event's `nativeEvent` is a `MouseEvent` not a `TouchEvent`. onClick: event => { var _this$_config2 = this._config, disabled = _this$_config2.disabled, onPress = _this$_config2.onPress; if (!disabled) { // If long press dispatched, cancel default click behavior. // If the responder terminated because text was selected during the gesture, // cancel the default click behavior. event.stopPropagation(); if (this._longPressDispatched || this._selectionTerminated) { event.preventDefault(); } else if (onPress != null && event.altKey === false) { onPress(event); } } else { if (isButtonRole(event.currentTarget)) { event.stopPropagation(); } } }, // If `onLongPress` is provided and a touch pointer is being used, prevent the // default context menu from opening. onContextMenu: event => { var _this$_config3 = this._config, disabled = _this$_config3.disabled, onLongPress = _this$_config3.onLongPress; if (!disabled) { if (onLongPress != null && this._isPointerTouch && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); } } else { if (isButtonRole(event.currentTarget)) { event.stopPropagation(); } } } }; } /** * Receives a state machine signal, performs side effects of the transition * and stores the new state. Validates the transition as well. */ _receiveSignal(signal, event) { var prevState = this._touchState; var nextState = null; if (Transitions[prevState] != null) { nextState = Transitions[prevState][signal]; } if (this._touchState === NOT_RESPONDER && signal === RESPONDER_RELEASE) { return; } if (nextState == null || nextState === ERROR) { console.error("PressResponder: Invalid signal " + signal + " for state " + prevState + " on responder"); } else if (prevState !== nextState) { this._performTransitionSideEffects(prevState, nextState, signal, event); this._touchState = nextState; } } /** * Performs a transition between touchable states and identify any activations * or deactivations (and callback invocations). */ _performTransitionSideEffects(prevState, nextState, signal, event) { if (isTerminalSignal(signal)) { // Pressable suppression of contextmenu on windows. // On Windows, the contextmenu is displayed after pointerup. // https://github.com/necolas/react-native-web/issues/2296 setTimeout(() => { this._isPointerTouch = false; }, 0); this._touchActivatePosition = null; this._cancelLongPressDelayTimeout(); } if (isPressStartSignal(prevState) && signal === LONG_PRESS_DETECTED) { var onLongPress = this._config.onLongPress; // Long press is not supported for keyboards because 'click' can be dispatched // immediately (and multiple times) after 'keydown'. if (onLongPress != null && event.nativeEvent.key == null) { onLongPress(event); this._longPressDispatched = true; } } var isPrevActive = isActiveSignal(prevState); var isNextActive = isActiveSignal(nextState); if (!isPrevActive && isNextActive) { this._activate(event); } else if (isPrevActive && !isNextActive) { this._deactivate(event); } if (isPressStartSignal(prevState) && signal === RESPONDER_RELEASE) { var _this$_config4 = this._config, _onLongPress = _this$_config4.onLongPress, onPress = _this$_config4.onPress; if (onPress != null) { var isPressCanceledByLongPress = _onLongPress != null && prevState === RESPONDER_ACTIVE_LONG_PRESS_START; if (!isPressCanceledByLongPress) { // If we never activated (due to delays), activate and deactivate now. if (!isNextActive && !isPrevActive) { this._activate(event); this._deactivate(event); } } } } this._cancelPressDelayTimeout(); } _activate(event) { var _this$_config5 = this._config, onPressChange = _this$_config5.onPressChange, onPressStart = _this$_config5.onPressStart; var touch = getTouchFromResponderEvent(event); this._touchActivatePosition = { pageX: touch.pageX, pageY: touch.pageY }; if (onPressStart != null) { onPressStart(event); } if (onPressChange != null) { onPressChange(true); } } _deactivate(event) { var _this$_config6 = this._config, onPressChange = _this$_config6.onPressChange, onPressEnd = _this$_config6.onPressEnd; function end() { if (onPressEnd != null) { onPressEnd(event); } if (onPressChange != null) { onPressChange(false); } } var delayPressEnd = normalizeDelay(this._config.delayPressEnd); if (delayPressEnd > 0) { this._pressOutDelayTimeout = setTimeout(() => { end(); }, delayPressEnd); } else { end(); } } _handleLongPress(event) { if (this._touchState === RESPONDER_ACTIVE_PRESS_START || this._touchState === RESPONDER_ACTIVE_LONG_PRESS_START) { this._receiveSignal(LONG_PRESS_DETECTED, event); } } _cancelLongPressDelayTimeout() { if (this._longPressDelayTimeout != null) { clearTimeout(this._longPressDelayTimeout); this._longPressDelayTimeout = null; } } _cancelPressDelayTimeout() { if (this._pressDelayTimeout != null) { clearTimeout(this._pressDelayTimeout); this._pressDelayTimeout = null; } } _cancelPressOutDelayTimeout() { if (this._pressOutDelayTimeout != null) { clearTimeout(this._pressOutDelayTimeout); this._pressOutDelayTimeout = null; } } } function normalizeDelay(delay, min, fallback) { if (min === void 0) { min = 0; } if (fallback === void 0) { fallback = 0; } return Math.max(min, delay !== null && delay !== void 0 ? delay : fallback); } function getTouchFromResponderEvent(event) { var _event$nativeEvent = event.nativeEvent, changedTouches = _event$nativeEvent.changedTouches, touches = _event$nativeEvent.touches; if (touches != null && touches.length > 0) { return touches[0]; } if (changedTouches != null && changedTouches.length > 0) { return changedTouches[0]; } return event.nativeEvent; }