You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
562 lines
18 KiB
562 lines
18 KiB
/**
|
|
* 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 (
|
|
* <View {...this.state.pressResponder.getEventHandlers()} />
|
|
* );
|
|
*
|
|
* 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;
|
|
} |