chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,41 @@
// Gesture Handlers
import PanGestureHandler from './handlers/PanGestureHandler';
import TapGestureHandler from './handlers/TapGestureHandler';
import LongPressGestureHandler from './handlers/LongPressGestureHandler';
import PinchGestureHandler from './handlers/PinchGestureHandler';
import RotationGestureHandler from './handlers/RotationGestureHandler';
import FlingGestureHandler from './handlers/FlingGestureHandler';
import NativeViewGestureHandler from './handlers/NativeViewGestureHandler';
import ManualGestureHandler from './handlers/ManualGestureHandler';
import HoverGestureHandler from './handlers/HoverGestureHandler';
// Hammer Handlers
import HammerNativeViewGestureHandler from '../web_hammer/NativeViewGestureHandler';
import HammerPanGestureHandler from '../web_hammer/PanGestureHandler';
import HammerTapGestureHandler from '../web_hammer/TapGestureHandler';
import HammerLongPressGestureHandler from '../web_hammer/LongPressGestureHandler';
import HammerPinchGestureHandler from '../web_hammer/PinchGestureHandler';
import HammerRotationGestureHandler from '../web_hammer/RotationGestureHandler';
import HammerFlingGestureHandler from '../web_hammer/FlingGestureHandler';
export const Gestures = {
NativeViewGestureHandler,
PanGestureHandler,
TapGestureHandler,
LongPressGestureHandler,
PinchGestureHandler,
RotationGestureHandler,
FlingGestureHandler,
ManualGestureHandler,
HoverGestureHandler,
};
export const HammerGestures = {
NativeViewGestureHandler: HammerNativeViewGestureHandler,
PanGestureHandler: HammerPanGestureHandler,
TapGestureHandler: HammerTapGestureHandler,
LongPressGestureHandler: HammerLongPressGestureHandler,
PinchGestureHandler: HammerPinchGestureHandler,
RotationGestureHandler: HammerRotationGestureHandler,
FlingGestureHandler: HammerFlingGestureHandler,
};
@@ -0,0 +1,2 @@
export const DEFAULT_TOUCH_SLOP = 15;
export const MINIMAL_RECOGNIZABLE_MAGNITUDE = 0.1;
@@ -0,0 +1,170 @@
import { AdaptedEvent, EventTypes } from '../interfaces';
import PointerTracker from '../tools/PointerTracker';
export interface RotationGestureListener {
onRotationBegin: (detector: RotationGestureDetector) => boolean;
onRotation: (detector: RotationGestureDetector) => boolean;
onRotationEnd: (detector: RotationGestureDetector) => void;
}
export default class RotationGestureDetector
implements RotationGestureListener
{
onRotationBegin: (detector: RotationGestureDetector) => boolean;
onRotation: (detector: RotationGestureDetector) => boolean;
onRotationEnd: (detector: RotationGestureDetector) => void;
private currentTime = 0;
private previousTime = 0;
private previousAngle = 0;
private _rotation = 0;
private _anchorX = 0;
private _anchorY = 0;
private isInProgress = false;
private keyPointers: number[] = [NaN, NaN];
constructor(callbacks: RotationGestureListener) {
this.onRotationBegin = callbacks.onRotationBegin;
this.onRotation = callbacks.onRotation;
this.onRotationEnd = callbacks.onRotationEnd;
}
private updateCurrent(event: AdaptedEvent, tracker: PointerTracker): void {
this.previousTime = this.currentTime;
this.currentTime = event.time;
const [firstPointerID, secondPointerID] = this.keyPointers;
const firstPointerCoords = tracker.getLastAbsoluteCoords(firstPointerID);
const secondPointerCoords = tracker.getLastAbsoluteCoords(secondPointerID);
if (!firstPointerCoords || !secondPointerCoords) {
return;
}
const vectorX: number = secondPointerCoords.x - firstPointerCoords.x;
const vectorY: number = secondPointerCoords.y - firstPointerCoords.y;
this._anchorX = (firstPointerCoords.x + secondPointerCoords.x) / 2;
this._anchorY = (firstPointerCoords.y + secondPointerCoords.y) / 2;
// Angle diff should be positive when rotating in clockwise direction
const angle: number = -Math.atan2(vectorY, vectorX);
this._rotation = Number.isNaN(this.previousAngle)
? 0
: this.previousAngle - angle;
this.previousAngle = angle;
if (this.rotation > Math.PI) {
this._rotation -= Math.PI;
} else if (this.rotation < -Math.PI) {
this._rotation += Math.PI;
}
if (this.rotation > Math.PI / 2) {
this._rotation -= Math.PI;
} else if (this.rotation < -Math.PI / 2) {
this._rotation += Math.PI;
}
}
private finish(): void {
if (!this.isInProgress) {
return;
}
this.isInProgress = false;
this.keyPointers = [NaN, NaN];
this.onRotationEnd(this);
}
private setKeyPointers(tracker: PointerTracker): void {
if (this.keyPointers[0] && this.keyPointers[1]) {
return;
}
const pointerIDs: IterableIterator<number> = tracker.trackedPointers.keys();
this.keyPointers[0] = pointerIDs.next().value as number;
this.keyPointers[1] = pointerIDs.next().value as number;
}
public onTouchEvent(event: AdaptedEvent, tracker: PointerTracker): boolean {
switch (event.eventType) {
case EventTypes.DOWN:
this.isInProgress = false;
break;
case EventTypes.ADDITIONAL_POINTER_DOWN:
if (this.isInProgress) {
break;
}
this.isInProgress = true;
this.previousTime = event.time;
this.previousAngle = NaN;
this.setKeyPointers(tracker);
this.updateCurrent(event, tracker);
this.onRotationBegin(this);
break;
case EventTypes.MOVE:
if (!this.isInProgress) {
break;
}
this.updateCurrent(event, tracker);
this.onRotation(this);
break;
case EventTypes.ADDITIONAL_POINTER_UP:
if (!this.isInProgress) {
break;
}
if (this.keyPointers.indexOf(event.pointerId) >= 0) {
this.finish();
}
break;
case EventTypes.UP:
if (this.isInProgress) {
this.finish();
}
break;
}
return true;
}
public reset(): void {
this.keyPointers = [NaN, NaN];
this.isInProgress = false;
}
public get anchorX() {
return this._anchorX;
}
public get anchorY() {
return this._anchorY;
}
public get rotation() {
return this._rotation;
}
public get timeDelta() {
return this.currentTime + this.previousTime;
}
}
@@ -0,0 +1,171 @@
import { DEFAULT_TOUCH_SLOP } from '../constants';
import { AdaptedEvent, EventTypes } from '../interfaces';
import PointerTracker from '../tools/PointerTracker';
export interface ScaleGestureListener {
onScaleBegin: (detector: ScaleGestureDetector) => boolean;
onScale: (detector: ScaleGestureDetector) => boolean;
onScaleEnd: (detector: ScaleGestureDetector) => void;
}
export default class ScaleGestureDetector implements ScaleGestureListener {
public onScaleBegin: (detector: ScaleGestureDetector) => boolean;
public onScale: (detector: ScaleGestureDetector) => boolean;
public onScaleEnd: (detector: ScaleGestureDetector) => void;
private _focusX!: number;
private _focusY!: number;
private _currentSpan!: number;
private prevSpan!: number;
private initialSpan!: number;
private currentTime!: number;
private prevTime!: number;
private inProgress = false;
private spanSlop: number;
private minSpan: number;
constructor(callbacks: ScaleGestureListener) {
this.onScaleBegin = callbacks.onScaleBegin;
this.onScale = callbacks.onScale;
this.onScaleEnd = callbacks.onScaleEnd;
this.spanSlop = DEFAULT_TOUCH_SLOP * 2;
this.minSpan = 0;
}
public onTouchEvent(event: AdaptedEvent, tracker: PointerTracker): boolean {
this.currentTime = event.time;
const action: EventTypes = event.eventType;
const numOfPointers = tracker.trackedPointersCount;
const streamComplete: boolean =
action === EventTypes.UP ||
action === EventTypes.ADDITIONAL_POINTER_UP ||
action === EventTypes.CANCEL;
if (action === EventTypes.DOWN || streamComplete) {
if (this.inProgress) {
this.onScaleEnd(this);
this.inProgress = false;
this.initialSpan = 0;
}
if (streamComplete) {
return true;
}
}
const configChanged: boolean =
action === EventTypes.DOWN ||
action === EventTypes.ADDITIONAL_POINTER_UP ||
action === EventTypes.ADDITIONAL_POINTER_DOWN;
const pointerUp = action === EventTypes.ADDITIONAL_POINTER_UP;
const ignoredPointer: number | undefined = pointerUp
? event.pointerId
: undefined;
// Determine focal point
const div: number = pointerUp ? numOfPointers - 1 : numOfPointers;
const coordsSum = tracker.getAbsoluteCoordsSum();
const focusX = coordsSum.x / div;
const focusY = coordsSum.y / div;
// Determine average deviation from focal point
let devSumX = 0;
let devSumY = 0;
tracker.trackedPointers.forEach((value, key) => {
if (key === ignoredPointer) {
return;
}
devSumX += Math.abs(value.abosoluteCoords.x - focusX);
devSumY += Math.abs(value.abosoluteCoords.y - focusY);
});
const devX: number = devSumX / div;
const devY: number = devSumY / div;
const spanX: number = devX * 2;
const spanY: number = devY * 2;
const span = Math.hypot(spanX, spanY);
// Begin/end events
const wasInProgress: boolean = this.inProgress;
this._focusX = focusX;
this._focusY = focusY;
if (this.inProgress && (span < this.minSpan || configChanged)) {
this.onScaleEnd(this);
this.inProgress = false;
this.initialSpan = span;
}
if (configChanged) {
this.initialSpan = this.prevSpan = this._currentSpan = span;
}
if (
!this.inProgress &&
span >= this.minSpan &&
(wasInProgress || Math.abs(span - this.initialSpan) > this.spanSlop)
) {
this.prevSpan = this._currentSpan = span;
this.prevTime = this.currentTime;
this.inProgress = this.onScaleBegin(this);
}
// Handle motion
if (action !== EventTypes.MOVE) {
return true;
}
this._currentSpan = span;
if (this.inProgress && !this.onScale(this)) {
return true;
}
this.prevSpan = this.currentSpan;
this.prevTime = this.currentTime;
return true;
}
public calculateScaleFactor(numOfPointers: number): number {
if (numOfPointers < 2) {
return 1;
}
return this.prevSpan > 0 ? this.currentSpan / this.prevSpan : 1;
}
public get currentSpan() {
return this._currentSpan;
}
public get focusX() {
return this._focusX;
}
public get focusY() {
return this._focusY;
}
public get timeDelta() {
return this.currentTime - this.prevTime;
}
}
@@ -0,0 +1,196 @@
import { State } from '../../State';
import { DiagonalDirections, Directions } from '../../Directions';
import { AdaptedEvent, Config } from '../interfaces';
import GestureHandler from './GestureHandler';
import Vector from '../tools/Vector';
import { coneToDeviation } from '../utils';
const DEFAULT_MAX_DURATION_MS = 800;
const DEFAULT_MIN_VELOCITY = 700;
const DEFAULT_ALIGNMENT_CONE = 30;
const DEFAULT_DIRECTION = Directions.RIGHT;
const DEFAULT_NUMBER_OF_TOUCHES_REQUIRED = 1;
const AXIAL_DEVIATION_COSINE = coneToDeviation(DEFAULT_ALIGNMENT_CONE);
const DIAGONAL_DEVIATION_COSINE = coneToDeviation(90 - DEFAULT_ALIGNMENT_CONE);
export default class FlingGestureHandler extends GestureHandler {
private numberOfPointersRequired = DEFAULT_NUMBER_OF_TOUCHES_REQUIRED;
private direction: Directions = DEFAULT_DIRECTION;
private maxDurationMs = DEFAULT_MAX_DURATION_MS;
private minVelocity = DEFAULT_MIN_VELOCITY;
private delayTimeout!: number;
private maxNumberOfPointersSimultaneously = 0;
private keyPointer = NaN;
public updateGestureConfig({ enabled = true, ...props }: Config): void {
super.updateGestureConfig({ enabled: enabled, ...props });
if (this.config.direction) {
this.direction = this.config.direction;
}
if (this.config.numberOfPointers) {
this.numberOfPointersRequired = this.config.numberOfPointers;
}
}
private startFling(): void {
this.begin();
this.maxNumberOfPointersSimultaneously = 1;
this.delayTimeout = setTimeout(() => this.fail(), this.maxDurationMs);
}
private tryEndFling(): boolean {
const velocityVector = Vector.fromVelocity(this.tracker, this.keyPointer);
if (!velocityVector) {
return false;
}
const getAlignment = (
direction: Directions | DiagonalDirections,
minimalAlignmentCosine: number
) => {
return (
(direction & this.direction) === direction &&
velocityVector.isSimilar(
Vector.fromDirection(direction),
minimalAlignmentCosine
)
);
};
const axialDirectionsList = Object.values(Directions);
const diagonalDirectionsList = Object.values(DiagonalDirections);
// List of alignments to all activated directions
const axialAlignmentList = axialDirectionsList.map((direction) =>
getAlignment(direction, AXIAL_DEVIATION_COSINE)
);
const diagonalAlignmentList = diagonalDirectionsList.map((direction) =>
getAlignment(direction, DIAGONAL_DEVIATION_COSINE)
);
const isAligned =
axialAlignmentList.some(Boolean) || diagonalAlignmentList.some(Boolean);
const isFast = velocityVector.magnitude > this.minVelocity;
if (
this.maxNumberOfPointersSimultaneously ===
this.numberOfPointersRequired &&
isAligned &&
isFast
) {
clearTimeout(this.delayTimeout);
this.activate();
return true;
}
return false;
}
private endFling() {
if (!this.tryEndFling()) {
this.fail();
}
}
protected onPointerDown(event: AdaptedEvent): void {
if (!this.isButtonInConfig(event.button)) {
return;
}
this.tracker.addToTracker(event);
this.keyPointer = event.pointerId;
super.onPointerDown(event);
this.newPointerAction();
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerAdd(event);
this.newPointerAction();
}
private newPointerAction(): void {
if (this.state === State.UNDETERMINED) {
this.startFling();
}
if (this.state !== State.BEGAN) {
return;
}
this.tryEndFling();
if (
this.tracker.trackedPointersCount > this.maxNumberOfPointersSimultaneously
) {
this.maxNumberOfPointersSimultaneously =
this.tracker.trackedPointersCount;
}
}
private pointerMoveAction(event: AdaptedEvent): void {
this.tracker.track(event);
if (this.state !== State.BEGAN) {
return;
}
this.tryEndFling();
}
protected onPointerMove(event: AdaptedEvent): void {
this.pointerMoveAction(event);
super.onPointerMove(event);
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
this.pointerMoveAction(event);
super.onPointerOutOfBounds(event);
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.onUp(event);
this.keyPointer = NaN;
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.onUp(event);
}
private onUp(event: AdaptedEvent): void {
if (this.state === State.BEGAN) {
this.endFling();
}
this.tracker.removeFromTracker(event.pointerId);
}
public activate(force?: boolean): void {
super.activate(force);
this.end();
}
protected resetConfig(): void {
super.resetConfig();
this.numberOfPointersRequired = DEFAULT_NUMBER_OF_TOUCHES_REQUIRED;
this.direction = DEFAULT_DIRECTION;
}
}
@@ -0,0 +1,881 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { State } from '../../State';
import {
Config,
AdaptedEvent,
PropsRef,
ResultEvent,
PointerData,
ResultTouchEvent,
TouchEventType,
EventTypes,
} from '../interfaces';
import EventManager from '../tools/EventManager';
import GestureHandlerOrchestrator from '../tools/GestureHandlerOrchestrator';
import InteractionManager from '../tools/InteractionManager';
import PointerTracker, { TrackerElement } from '../tools/PointerTracker';
import IGestureHandler from './IGestureHandler';
import { MouseButton } from '../../handlers/gestureHandlerCommon';
import { PointerType } from '../../PointerType';
import { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate';
export default abstract class GestureHandler implements IGestureHandler {
private lastSentState: State | null = null;
private _state: State = State.UNDETERMINED;
private _shouldCancelWhenOutside = false;
protected hasCustomActivationCriteria = false;
private _enabled = false;
private viewRef!: number;
private propsRef!: React.RefObject<unknown>;
private _handlerTag!: number;
private _config: Config = { enabled: false };
private _tracker: PointerTracker = new PointerTracker();
// Orchestrator properties
private _activationIndex = 0;
private _awaiting = false;
private _active = false;
private _shouldResetProgress = false;
private _pointerType: PointerType = PointerType.MOUSE;
private _delegate: GestureHandlerDelegate<unknown, IGestureHandler>;
public constructor(
delegate: GestureHandlerDelegate<unknown, IGestureHandler>
) {
this._delegate = delegate;
}
//
// Initializing handler
//
protected init(viewRef: number, propsRef: React.RefObject<unknown>) {
this.propsRef = propsRef;
this.viewRef = viewRef;
this.state = State.UNDETERMINED;
this.delegate.init(viewRef, this);
}
public attachEventManager(manager: EventManager<unknown>): void {
manager.setOnPointerDown(this.onPointerDown.bind(this));
manager.setOnPointerAdd(this.onPointerAdd.bind(this));
manager.setOnPointerUp(this.onPointerUp.bind(this));
manager.setOnPointerRemove(this.onPointerRemove.bind(this));
manager.setOnPointerMove(this.onPointerMove.bind(this));
manager.setOnPointerEnter(this.onPointerEnter.bind(this));
manager.setOnPointerLeave(this.onPointerLeave.bind(this));
manager.setOnPointerCancel(this.onPointerCancel.bind(this));
manager.setOnPointerOutOfBounds(this.onPointerOutOfBounds.bind(this));
manager.setOnPointerMoveOver(this.onPointerMoveOver.bind(this));
manager.setOnPointerMoveOut(this.onPointerMoveOut.bind(this));
manager.setOnWheel(this.onWheel.bind(this));
manager.registerListeners();
}
//
// Resetting handler
//
protected onCancel(): void {}
protected onReset(): void {}
protected resetProgress(): void {}
public reset(): void {
this.tracker.resetTracker();
this.onReset();
this.resetProgress();
this.delegate.reset();
this.state = State.UNDETERMINED;
}
//
// State logic
//
public moveToState(newState: State, sendIfDisabled?: boolean) {
if (this.state === newState) {
return;
}
const oldState = this.state;
this.state = newState;
if (
this.tracker.trackedPointersCount > 0 &&
this.config.needsPointerData &&
this.isFinished()
) {
this.cancelTouches();
}
GestureHandlerOrchestrator.instance.onHandlerStateChange(
this,
newState,
oldState,
sendIfDisabled
);
this.onStateChange(newState, oldState);
if (!this.enabled && this.isFinished()) {
this.state = State.UNDETERMINED;
}
}
protected onStateChange(_newState: State, _oldState: State): void {}
public begin(): void {
if (!this.checkHitSlop()) {
return;
}
if (this.state === State.UNDETERMINED) {
this.moveToState(State.BEGAN);
}
}
/**
* @param {boolean} sendIfDisabled - Used when handler becomes disabled. With this flag orchestrator will be forced to send fail event
*/
public fail(sendIfDisabled?: boolean): void {
if (this.state === State.ACTIVE || this.state === State.BEGAN) {
// Here the order of calling the delegate and moveToState is important.
// At this point we can use currentState as previuos state, because immediately after changing cursor we call moveToState method.
this.delegate.onFail();
this.moveToState(State.FAILED, sendIfDisabled);
}
this.resetProgress();
}
/**
* @param {boolean} sendIfDisabled - Used when handler becomes disabled. With this flag orchestrator will be forced to send cancel event
*/
public cancel(sendIfDisabled?: boolean): void {
if (
this.state === State.ACTIVE ||
this.state === State.UNDETERMINED ||
this.state === State.BEGAN
) {
this.onCancel();
// Same as above - order matters
this.delegate.onCancel();
this.moveToState(State.CANCELLED, sendIfDisabled);
}
}
public activate(force = false) {
if (
(this.config.manualActivation !== true || force) &&
(this.state === State.UNDETERMINED || this.state === State.BEGAN)
) {
this.delegate.onActivate();
this.moveToState(State.ACTIVE);
}
}
public end() {
if (this.state === State.BEGAN || this.state === State.ACTIVE) {
// Same as above - order matters
this.delegate.onEnd();
this.moveToState(State.END);
}
this.resetProgress();
}
//
// Methods for orchestrator
//
public getShouldResetProgress(): boolean {
return this.shouldResetProgress;
}
public setShouldResetProgress(value: boolean): void {
this.shouldResetProgress = value;
}
public shouldWaitForHandlerFailure(handler: IGestureHandler): boolean {
if (handler === this) {
return false;
}
return InteractionManager.instance.shouldWaitForHandlerFailure(
this,
handler
);
}
public shouldRequireToWaitForFailure(handler: IGestureHandler): boolean {
if (handler === this) {
return false;
}
return InteractionManager.instance.shouldRequireHandlerToWaitForFailure(
this,
handler
);
}
public shouldRecognizeSimultaneously(handler: IGestureHandler): boolean {
if (handler === this) {
return true;
}
return InteractionManager.instance.shouldRecognizeSimultaneously(
this,
handler
);
}
public shouldBeCancelledByOther(handler: IGestureHandler): boolean {
if (handler === this) {
return false;
}
return InteractionManager.instance.shouldHandlerBeCancelledBy(
this,
handler
);
}
//
// Event actions
//
protected onPointerDown(event: AdaptedEvent): void {
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(this);
this.pointerType = event.pointerType;
if (this.pointerType === PointerType.TOUCH) {
GestureHandlerOrchestrator.instance.cancelMouseAndPenGestures(this);
}
// TODO: Bring back touch events along with introducing `handleDown` method that will handle handler specific stuff
}
// Adding another pointer to existing ones
protected onPointerAdd(event: AdaptedEvent): void {
this.tryToSendTouchEvent(event);
}
protected onPointerUp(event: AdaptedEvent): void {
this.tryToSendTouchEvent(event);
}
// Removing pointer, when there is more than one pointers
protected onPointerRemove(event: AdaptedEvent): void {
this.tryToSendTouchEvent(event);
}
protected onPointerMove(event: AdaptedEvent): void {
this.tryToSendMoveEvent(false, event);
}
protected onPointerLeave(event: AdaptedEvent): void {
if (this.shouldCancelWhenOutside) {
switch (this.state) {
case State.ACTIVE:
this.cancel();
break;
case State.BEGAN:
this.fail();
break;
}
return;
}
this.tryToSendTouchEvent(event);
}
protected onPointerEnter(event: AdaptedEvent): void {
this.tryToSendTouchEvent(event);
}
protected onPointerCancel(event: AdaptedEvent): void {
this.tryToSendTouchEvent(event);
this.cancel();
this.reset();
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
this.tryToSendMoveEvent(true, event);
}
protected onPointerMoveOver(_event: AdaptedEvent): void {
// Used only by hover gesture handler atm
}
protected onPointerMoveOut(_event: AdaptedEvent): void {
// Used only by hover gesture handler atm
}
protected onWheel(_event: AdaptedEvent): void {
// Used only by pan gesture handler
}
protected tryToSendMoveEvent(out: boolean, event: AdaptedEvent): void {
if ((out && this.shouldCancelWhenOutside) || !this.enabled) {
return;
}
if (this.active) {
this.sendEvent(this.state, this.state);
}
this.tryToSendTouchEvent(event);
}
protected tryToSendTouchEvent(event: AdaptedEvent): void {
if (this.config.needsPointerData) {
this.sendTouchEvent(event);
}
}
public sendTouchEvent(event: AdaptedEvent): void {
if (!this.enabled) {
return;
}
const { onGestureHandlerEvent }: PropsRef = this.propsRef
.current as PropsRef;
const touchEvent: ResultTouchEvent | undefined =
this.transformTouchEvent(event);
if (touchEvent) {
invokeNullableMethod(onGestureHandlerEvent, touchEvent);
}
}
//
// Events Sending
//
public sendEvent = (newState: State, oldState: State): void => {
const { onGestureHandlerEvent, onGestureHandlerStateChange }: PropsRef =
this.propsRef.current as PropsRef;
const resultEvent: ResultEvent = this.transformEventData(
newState,
oldState
);
// In the new API oldState field has to be undefined, unless we send event state changed
// Here the order is flipped to avoid workarounds such as making backup of the state and setting it to undefined first, then changing it back
// Flipping order with setting oldState to undefined solves issue, when events were being sent twice instead of once
// However, this may cause trouble in the future (but for now we don't know that)
if (this.lastSentState !== newState) {
this.lastSentState = newState;
invokeNullableMethod(onGestureHandlerStateChange, resultEvent);
}
if (this.state === State.ACTIVE) {
resultEvent.nativeEvent.oldState = undefined;
invokeNullableMethod(onGestureHandlerEvent, resultEvent);
}
};
private transformEventData(newState: State, oldState: State): ResultEvent {
return {
nativeEvent: {
numberOfPointers: this.tracker.trackedPointersCount,
state: newState,
pointerInside: this.delegate.isPointerInBounds(
this.tracker.getAbsoluteCoordsAverage()
),
...this.transformNativeEvent(),
handlerTag: this.handlerTag,
target: this.viewRef,
oldState: newState !== oldState ? oldState : undefined,
pointerType: this.pointerType,
},
timeStamp: Date.now(),
};
}
private transformTouchEvent(
event: AdaptedEvent
): ResultTouchEvent | undefined {
const rect = this.delegate.measureView();
const all: PointerData[] = [];
const changed: PointerData[] = [];
const trackerData = this.tracker.trackedPointers;
// This if handles edge case where all pointers have been cancelled
// When pointercancel is triggered, reset method is called. This means that tracker will be reset after first pointer being cancelled
// The problem is, that handler will receive another pointercancel event from the rest of the pointers
// To avoid crashing, we don't send event if tracker tracks no pointers, i.e. has been reset
if (trackerData.size === 0 || !trackerData.has(event.pointerId)) {
return;
}
trackerData.forEach((element: TrackerElement, key: number): void => {
const id: number = this.tracker.getMappedTouchEventId(key);
all.push({
id: id,
x: element.abosoluteCoords.x - rect.pageX,
y: element.abosoluteCoords.y - rect.pageY,
absoluteX: element.abosoluteCoords.x,
absoluteY: element.abosoluteCoords.y,
});
});
// Each pointer sends its own event, so we want changed touches to contain only the pointer that has changed.
// However, if the event is cancel, we want to cancel all pointers to avoid crashes
if (event.eventType !== EventTypes.CANCEL) {
changed.push({
id: this.tracker.getMappedTouchEventId(event.pointerId),
x: event.x - rect.pageX,
y: event.y - rect.pageY,
absoluteX: event.x,
absoluteY: event.y,
});
} else {
trackerData.forEach((element: TrackerElement, key: number): void => {
const id: number = this.tracker.getMappedTouchEventId(key);
changed.push({
id: id,
x: element.abosoluteCoords.x - rect.pageX,
y: element.abosoluteCoords.y - rect.pageY,
absoluteX: element.abosoluteCoords.x,
absoluteY: element.abosoluteCoords.y,
});
});
}
let eventType: TouchEventType = TouchEventType.UNDETERMINED;
switch (event.eventType) {
case EventTypes.DOWN:
case EventTypes.ADDITIONAL_POINTER_DOWN:
eventType = TouchEventType.DOWN;
break;
case EventTypes.UP:
case EventTypes.ADDITIONAL_POINTER_UP:
eventType = TouchEventType.UP;
break;
case EventTypes.MOVE:
eventType = TouchEventType.MOVE;
break;
case EventTypes.CANCEL:
eventType = TouchEventType.CANCELLED;
break;
}
// Here, when we receive up event, we want to decrease number of touches
// That's because we want handler to send information that there's one pointer less
// However, we still want this pointer to be present in allTouches array, so that its data can be accessed
let numberOfTouches: number = all.length;
if (
event.eventType === EventTypes.UP ||
event.eventType === EventTypes.ADDITIONAL_POINTER_UP
) {
--numberOfTouches;
}
return {
nativeEvent: {
handlerTag: this.handlerTag,
state: this.state,
eventType: eventType,
changedTouches: changed,
allTouches: all,
numberOfTouches: numberOfTouches,
pointerType: this.pointerType,
},
timeStamp: Date.now(),
};
}
private cancelTouches(): void {
const rect = this.delegate.measureView();
const all: PointerData[] = [];
const changed: PointerData[] = [];
const trackerData = this.tracker.trackedPointers;
if (trackerData.size === 0) {
return;
}
trackerData.forEach((element: TrackerElement, key: number): void => {
const id: number = this.tracker.getMappedTouchEventId(key);
all.push({
id: id,
x: element.abosoluteCoords.x - rect.pageX,
y: element.abosoluteCoords.y - rect.pageY,
absoluteX: element.abosoluteCoords.x,
absoluteY: element.abosoluteCoords.y,
});
changed.push({
id: id,
x: element.abosoluteCoords.x - rect.pageX,
y: element.abosoluteCoords.y - rect.pageY,
absoluteX: element.abosoluteCoords.x,
absoluteY: element.abosoluteCoords.y,
});
});
const cancelEvent: ResultTouchEvent = {
nativeEvent: {
handlerTag: this.handlerTag,
state: this.state,
eventType: TouchEventType.CANCELLED,
changedTouches: changed,
allTouches: all,
numberOfTouches: all.length,
pointerType: this.pointerType,
},
timeStamp: Date.now(),
};
const { onGestureHandlerEvent }: PropsRef = this.propsRef
.current as PropsRef;
invokeNullableMethod(onGestureHandlerEvent, cancelEvent);
}
protected transformNativeEvent(): Record<string, unknown> {
// Those properties are shared by most handlers and if not this method will be overriden
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
const lastRelativeCoords = this.tracker.getRelativeCoordsAverage();
return {
x: lastRelativeCoords.x,
y: lastRelativeCoords.y,
absoluteX: lastCoords.x,
absoluteY: lastCoords.y,
};
}
//
// Handling config
//
public updateGestureConfig({ enabled = true, ...props }: Config): void {
this._config = { enabled: enabled, ...props };
if (this.enabled !== enabled) {
this.delegate.onEnabledChange(enabled);
}
this.enabled = enabled;
if (this.config.shouldCancelWhenOutside !== undefined) {
this.shouldCancelWhenOutside = this.config.shouldCancelWhenOutside;
}
this.validateHitSlops();
if (this.enabled) {
return;
}
switch (this.state) {
case State.ACTIVE:
this.fail(true);
break;
case State.UNDETERMINED:
GestureHandlerOrchestrator.instance.removeHandlerFromOrchestrator(this);
break;
default:
this.cancel(true);
break;
}
}
protected checkCustomActivationCriteria(criterias: string[]): void {
for (const key in this.config) {
if (criterias.indexOf(key) >= 0) {
this.hasCustomActivationCriteria = true;
}
}
}
private validateHitSlops(): void {
if (!this.config.hitSlop) {
return;
}
if (
this.config.hitSlop.left !== undefined &&
this.config.hitSlop.right !== undefined &&
this.config.hitSlop.width !== undefined
) {
throw new Error(
'HitSlop Error: Cannot define left, right and width at the same time'
);
}
if (
this.config.hitSlop.width !== undefined &&
this.config.hitSlop.left === undefined &&
this.config.hitSlop.right === undefined
) {
throw new Error(
'HitSlop Error: When width is defined, either left or right has to be defined'
);
}
if (
this.config.hitSlop.height !== undefined &&
this.config.hitSlop.top !== undefined &&
this.config.hitSlop.bottom !== undefined
) {
throw new Error(
'HitSlop Error: Cannot define top, bottom and height at the same time'
);
}
if (
this.config.hitSlop.height !== undefined &&
this.config.hitSlop.top === undefined &&
this.config.hitSlop.bottom === undefined
) {
throw new Error(
'HitSlop Error: When height is defined, either top or bottom has to be defined'
);
}
}
private checkHitSlop(): boolean {
if (!this.config.hitSlop) {
return true;
}
const { width, height } = this.delegate.measureView();
let left = 0;
let top = 0;
let right: number = width;
let bottom: number = height;
if (this.config.hitSlop.horizontal !== undefined) {
left -= this.config.hitSlop.horizontal;
right += this.config.hitSlop.horizontal;
}
if (this.config.hitSlop.vertical !== undefined) {
top -= this.config.hitSlop.vertical;
bottom += this.config.hitSlop.vertical;
}
if (this.config.hitSlop.left !== undefined) {
left = -this.config.hitSlop.left;
}
if (this.config.hitSlop.right !== undefined) {
right = width + this.config.hitSlop.right;
}
if (this.config.hitSlop.top !== undefined) {
top = -this.config.hitSlop.top;
}
if (this.config.hitSlop.bottom !== undefined) {
bottom = height + this.config.hitSlop.bottom;
}
if (this.config.hitSlop.width !== undefined) {
if (this.config.hitSlop.left !== undefined) {
right = left + this.config.hitSlop.width;
} else if (this.config.hitSlop.right !== undefined) {
left = right - this.config.hitSlop.width;
}
}
if (this.config.hitSlop.height !== undefined) {
if (this.config.hitSlop.top !== undefined) {
bottom = top + this.config.hitSlop.height;
} else if (this.config.hitSlop.bottom !== undefined) {
top = bottom - this.config.hitSlop.height;
}
}
const rect = this.delegate.measureView();
const lastCoords = this.tracker.getLastAbsoluteCoords();
if (!lastCoords) {
return false;
}
const offsetX: number = lastCoords.x - rect.pageX;
const offsetY: number = lastCoords.y - rect.pageY;
return (
offsetX >= left && offsetX <= right && offsetY >= top && offsetY <= bottom
);
}
public isButtonInConfig(mouseButton: MouseButton | undefined) {
return (
!mouseButton ||
(!this.config.mouseButton && mouseButton === MouseButton.LEFT) ||
(this.config.mouseButton && mouseButton & this.config.mouseButton)
);
}
protected resetConfig(): void {}
public onDestroy(): void {
GestureHandlerOrchestrator.instance.removeHandlerFromOrchestrator(this);
this.delegate.destroy(this.config);
}
//
// Getters and setters
//
public get handlerTag() {
return this._handlerTag;
}
public set handlerTag(value: number) {
this._handlerTag = value;
}
public get config(): Config {
return this._config;
}
public get delegate() {
return this._delegate;
}
public get tracker() {
return this._tracker;
}
public get state(): State {
return this._state;
}
protected set state(value: State) {
this._state = value;
}
public get shouldCancelWhenOutside() {
return this._shouldCancelWhenOutside;
}
protected set shouldCancelWhenOutside(value) {
this._shouldCancelWhenOutside = value;
}
public get enabled() {
return this._enabled;
}
protected set enabled(value) {
this._enabled = value;
}
public get pointerType(): PointerType {
return this._pointerType;
}
protected set pointerType(value: PointerType) {
this._pointerType = value;
}
public get active() {
return this._active;
}
protected set active(value) {
this._active = value;
}
public get awaiting() {
return this._awaiting;
}
protected set awaiting(value) {
this._awaiting = value;
}
public get activationIndex() {
return this._activationIndex;
}
protected set activationIndex(value) {
this._activationIndex = value;
}
public get shouldResetProgress() {
return this._shouldResetProgress;
}
protected set shouldResetProgress(value) {
this._shouldResetProgress = value;
}
public getTrackedPointersID(): number[] {
return this.tracker.trackedPointersIDs;
}
private isFinished(): boolean {
return (
this.state === State.END ||
this.state === State.FAILED ||
this.state === State.CANCELLED
);
}
}
function invokeNullableMethod(
method:
| ((event: ResultEvent | ResultTouchEvent) => void)
| { __getHandler: () => (event: ResultEvent | ResultTouchEvent) => void }
| { __nodeConfig: { argMapping: unknown[] } },
event: ResultEvent | ResultTouchEvent
): void {
if (!method) {
return;
}
if (typeof method === 'function') {
method(event);
return;
}
if ('__getHandler' in method && typeof method.__getHandler === 'function') {
const handler = method.__getHandler();
invokeNullableMethod(handler, event);
return;
}
if (!('__nodeConfig' in method)) {
return;
}
const { argMapping }: { argMapping: unknown } = method.__nodeConfig;
if (!Array.isArray(argMapping)) {
return;
}
for (const [index, [key, value]] of argMapping.entries()) {
if (!(key in event.nativeEvent)) {
continue;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const nativeValue = event.nativeEvent[key];
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (value?.setValue) {
// Reanimated API
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
value.setValue(nativeValue);
} else {
// RN Animated API
method.__nodeConfig.argMapping[index] = [key, nativeValue];
}
}
return;
}
@@ -0,0 +1,49 @@
import { State } from '../../State';
import { AdaptedEvent, StylusData } from '../interfaces';
import GestureHandlerOrchestrator from '../tools/GestureHandlerOrchestrator';
import GestureHandler from './GestureHandler';
export default class HoverGestureHandler extends GestureHandler {
private stylusData: StylusData | undefined;
protected transformNativeEvent(): Record<string, unknown> {
return {
...super.transformNativeEvent(),
stylusData: this.stylusData,
};
}
protected onPointerMoveOver(event: AdaptedEvent): void {
GestureHandlerOrchestrator.instance.recordHandlerIfNotPresent(this);
this.tracker.addToTracker(event);
this.stylusData = event.stylusData;
super.onPointerMoveOver(event);
if (this.state === State.UNDETERMINED) {
this.begin();
this.activate();
}
}
protected onPointerMoveOut(event: AdaptedEvent): void {
this.tracker.removeFromTracker(event.pointerId);
this.stylusData = event.stylusData;
super.onPointerMoveOut(event);
this.end();
}
protected onPointerMove(event: AdaptedEvent): void {
this.tracker.track(event);
this.stylusData = event.stylusData;
super.onPointerMove(event);
}
protected onPointerCancel(event: AdaptedEvent): void {
super.onPointerCancel(event);
this.reset();
}
}
@@ -0,0 +1,49 @@
import type { PointerType } from '../../PointerType';
import type { MouseButton } from '../../handlers/gestureHandlerCommon';
import type { State } from '../../State';
import type { Config } from '../interfaces';
import type EventManager from '../tools/EventManager';
import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate';
import type PointerTracker from '../tools/PointerTracker';
export default interface IGestureHandler {
active: boolean;
activationIndex: number;
awaiting: boolean;
handlerTag: number;
readonly config: Config;
readonly delegate: GestureHandlerDelegate<unknown, this>;
readonly tracker: PointerTracker;
state: State;
shouldCancelWhenOutside: boolean;
shouldResetProgress: boolean;
enabled: boolean;
pointerType: PointerType;
attachEventManager: (manager: EventManager<unknown>) => void;
isButtonInConfig: (
mouseButton: MouseButton | undefined
) => boolean | number | undefined;
getTrackedPointersID: () => number[];
begin: () => void;
activate: (force: boolean) => void;
end: () => void;
fail: () => void;
cancel: () => void;
reset: () => void;
shouldWaitForHandlerFailure: (handler: IGestureHandler) => boolean;
shouldRequireToWaitForFailure: (handler: IGestureHandler) => boolean;
shouldRecognizeSimultaneously: (handler: IGestureHandler) => boolean;
shouldBeCancelledByOther: (handler: IGestureHandler) => boolean;
sendEvent: (newState: State, oldState: State) => void;
updateGestureConfig: (config: Config) => void;
isButton?: () => boolean;
}
@@ -0,0 +1,175 @@
import { State } from '../../State';
import { AdaptedEvent, Config } from '../interfaces';
import GestureHandler from './GestureHandler';
const DEFAULT_MIN_DURATION_MS = 500;
const DEFAULT_MAX_DIST_DP = 10;
const SCALING_FACTOR = 10;
export default class LongPressGestureHandler extends GestureHandler {
private minDurationMs = DEFAULT_MIN_DURATION_MS;
private defaultMaxDistSq = DEFAULT_MAX_DIST_DP * SCALING_FACTOR;
private maxDistSq = this.defaultMaxDistSq;
private numberOfPointers = 1;
private startX = 0;
private startY = 0;
private startTime = 0;
private previousTime = 0;
private activationTimeout: number | undefined;
public init(ref: number, propsRef: React.RefObject<unknown>) {
if (this.config.enableContextMenu === undefined) {
this.config.enableContextMenu = false;
}
super.init(ref, propsRef);
}
protected transformNativeEvent() {
return {
...super.transformNativeEvent(),
duration: Date.now() - this.startTime,
};
}
public updateGestureConfig({ enabled = true, ...props }: Config): void {
super.updateGestureConfig({ enabled: enabled, ...props });
if (this.config.minDurationMs !== undefined) {
this.minDurationMs = this.config.minDurationMs;
}
if (this.config.maxDist !== undefined) {
this.maxDistSq = this.config.maxDist * this.config.maxDist;
}
if (this.config.numberOfPointers !== undefined) {
this.numberOfPointers = this.config.numberOfPointers;
}
}
protected resetConfig(): void {
super.resetConfig();
this.minDurationMs = DEFAULT_MIN_DURATION_MS;
this.maxDistSq = this.defaultMaxDistSq;
}
protected onStateChange(_newState: State, _oldState: State): void {
clearTimeout(this.activationTimeout);
}
protected onPointerDown(event: AdaptedEvent): void {
if (!this.isButtonInConfig(event.button)) {
return;
}
this.tracker.addToTracker(event);
super.onPointerDown(event);
this.startX = event.x;
this.startY = event.y;
this.tryBegin();
this.tryActivate();
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
super.onPointerAdd(event);
this.tracker.addToTracker(event);
if (this.tracker.trackedPointersCount > this.numberOfPointers) {
this.fail();
return;
}
const absoluteCoordsAverage = this.tracker.getAbsoluteCoordsAverage();
this.startX = absoluteCoordsAverage.x;
this.startY = absoluteCoordsAverage.y;
this.tryActivate();
}
protected onPointerMove(event: AdaptedEvent): void {
super.onPointerMove(event);
this.tracker.track(event);
this.checkDistanceFail();
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
super.onPointerOutOfBounds(event);
this.tracker.track(event);
this.checkDistanceFail();
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.tracker.removeFromTracker(event.pointerId);
if (this.state === State.ACTIVE) {
this.end();
} else {
this.fail();
}
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.tracker.removeFromTracker(event.pointerId);
if (
this.tracker.trackedPointersCount < this.numberOfPointers &&
this.state !== State.ACTIVE
) {
this.fail();
}
}
private tryBegin(): void {
if (this.state !== State.UNDETERMINED) {
return;
}
this.previousTime = Date.now();
this.startTime = this.previousTime;
this.begin();
}
private tryActivate(): void {
if (this.tracker.trackedPointersCount !== this.numberOfPointers) {
return;
}
if (this.minDurationMs > 0) {
this.activationTimeout = setTimeout(() => {
this.activate();
}, this.minDurationMs);
} else if (this.minDurationMs === 0) {
this.activate();
}
}
private checkDistanceFail(): void {
const absoluteCoordsAverage = this.tracker.getAbsoluteCoordsAverage();
const dx = absoluteCoordsAverage.x - this.startX;
const dy = absoluteCoordsAverage.y - this.startY;
const distSq = dx * dx + dy * dy;
if (distSq <= this.maxDistSq) {
return;
}
if (this.state === State.ACTIVE) {
this.cancel();
} else {
this.fail();
}
}
}
@@ -0,0 +1,37 @@
import { AdaptedEvent } from '../interfaces';
import GestureHandler from './GestureHandler';
export default class ManualGestureHandler extends GestureHandler {
protected onPointerDown(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerDown(event);
this.begin();
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerAdd(event);
}
protected onPointerMove(event: AdaptedEvent): void {
this.tracker.track(event);
super.onPointerMove(event);
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
this.tracker.track(event);
super.onPointerOutOfBounds(event);
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.tracker.removeFromTracker(event.pointerId);
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.tracker.removeFromTracker(event.pointerId);
}
}
@@ -0,0 +1,175 @@
import { Platform } from 'react-native';
import { State } from '../../State';
import { DEFAULT_TOUCH_SLOP } from '../constants';
import { AdaptedEvent, Config } from '../interfaces';
import GestureHandler from './GestureHandler';
export default class NativeViewGestureHandler extends GestureHandler {
private buttonRole!: boolean;
// TODO: Implement logic for activation on start
// @ts-ignore Logic yet to be implemented
private shouldActivateOnStart = false;
private disallowInterruption = false;
private startX = 0;
private startY = 0;
private minDistSq = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP;
public init(ref: number, propsRef: React.RefObject<unknown>): void {
super.init(ref, propsRef);
this.shouldCancelWhenOutside = true;
if (Platform.OS !== 'web') {
return;
}
const view = this.delegate.view as HTMLElement;
this.restoreViewStyles(view);
this.buttonRole = view.getAttribute('role') === 'button';
}
public updateGestureConfig({ enabled = true, ...props }: Config): void {
super.updateGestureConfig({ enabled: enabled, ...props });
if (this.config.shouldActivateOnStart !== undefined) {
this.shouldActivateOnStart = this.config.shouldActivateOnStart;
}
if (this.config.disallowInterruption !== undefined) {
this.disallowInterruption = this.config.disallowInterruption;
}
const view = this.delegate.view as HTMLElement;
this.restoreViewStyles(view);
}
private restoreViewStyles(view: HTMLElement) {
if (!view) {
return;
}
view.style['touchAction'] = 'auto';
// @ts-ignore Turns on defualt touch behavior on Safari
view.style['WebkitTouchCallout'] = 'auto';
}
protected onPointerDown(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerDown(event);
this.newPointerAction();
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerAdd(event);
this.newPointerAction();
}
private newPointerAction(): void {
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
this.startX = lastCoords.x;
this.startY = lastCoords.y;
if (this.state !== State.UNDETERMINED) {
return;
}
this.begin();
const view = this.delegate.view as HTMLElement;
const isRNGHText = view.hasAttribute('rnghtext');
if (this.buttonRole || isRNGHText) {
this.activate();
}
}
protected onPointerMove(event: AdaptedEvent): void {
this.tracker.track(event);
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
const dx = this.startX - lastCoords.x;
const dy = this.startY - lastCoords.y;
const distSq = dx * dx + dy * dy;
if (distSq >= this.minDistSq) {
if (this.buttonRole && this.state === State.ACTIVE) {
this.cancel();
} else if (!this.buttonRole && this.state === State.BEGAN) {
this.activate();
}
}
}
protected onPointerLeave(): void {
if (this.state === State.BEGAN || this.state === State.ACTIVE) {
this.cancel();
}
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.onUp(event);
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.onUp(event);
}
private onUp(event: AdaptedEvent): void {
this.tracker.removeFromTracker(event.pointerId);
if (this.tracker.trackedPointersCount === 0) {
if (this.state === State.ACTIVE) {
this.end();
} else {
this.fail();
}
}
}
public shouldRecognizeSimultaneously(handler: GestureHandler): boolean {
if (super.shouldRecognizeSimultaneously(handler)) {
return true;
}
if (
handler instanceof NativeViewGestureHandler &&
handler.state === State.ACTIVE &&
handler.disallowsInterruption()
) {
return false;
}
const canBeInterrupted = !this.disallowInterruption;
if (
this.state === State.ACTIVE &&
handler.state === State.ACTIVE &&
canBeInterrupted
) {
return false;
}
return (
this.state === State.ACTIVE && canBeInterrupted && handler.handlerTag > 0
);
}
public shouldBeCancelledByOther(_handler: GestureHandler): boolean {
return !this.disallowInterruption;
}
public disallowsInterruption(): boolean {
return this.disallowInterruption;
}
public isButton(): boolean {
return this.buttonRole;
}
}
@@ -0,0 +1,572 @@
import { State } from '../../State';
import { DEFAULT_TOUCH_SLOP } from '../constants';
import { AdaptedEvent, Config, StylusData, WheelDevice } from '../interfaces';
import GestureHandler from './GestureHandler';
const DEFAULT_MIN_POINTERS = 1;
const DEFAULT_MAX_POINTERS = 10;
const DEFAULT_MIN_DIST_SQ = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP;
export default class PanGestureHandler extends GestureHandler {
private readonly customActivationProperties: string[] = [
'activeOffsetXStart',
'activeOffsetXEnd',
'failOffsetXStart',
'failOffsetXEnd',
'activeOffsetYStart',
'activeOffsetYEnd',
'failOffsetYStart',
'failOffsetYEnd',
'minVelocityX',
'minVelocityY',
'minVelocity',
];
public velocityX = 0;
public velocityY = 0;
private minDistSq = DEFAULT_MIN_DIST_SQ;
private activeOffsetXStart = -Number.MAX_SAFE_INTEGER;
private activeOffsetXEnd = Number.MIN_SAFE_INTEGER;
private failOffsetXStart = Number.MIN_SAFE_INTEGER;
private failOffsetXEnd = Number.MAX_SAFE_INTEGER;
private activeOffsetYStart = Number.MAX_SAFE_INTEGER;
private activeOffsetYEnd = Number.MIN_SAFE_INTEGER;
private failOffsetYStart = Number.MIN_SAFE_INTEGER;
private failOffsetYEnd = Number.MAX_SAFE_INTEGER;
private minVelocityX = Number.MAX_SAFE_INTEGER;
private minVelocityY = Number.MAX_SAFE_INTEGER;
private minVelocitySq = Number.MAX_SAFE_INTEGER;
private minPointers = DEFAULT_MIN_POINTERS;
private maxPointers = DEFAULT_MAX_POINTERS;
private startX = 0;
private startY = 0;
private offsetX = 0;
private offsetY = 0;
private lastX = 0;
private lastY = 0;
private stylusData: StylusData | undefined;
private activateAfterLongPress = 0;
private activationTimeout = 0;
private enableTrackpadTwoFingerGesture = false;
private endWheelTimeout = 0;
private wheelDevice = WheelDevice.UNDETERMINED;
public updateGestureConfig({ enabled = true, ...props }: Config): void {
this.resetConfig();
super.updateGestureConfig({ enabled: enabled, ...props });
this.checkCustomActivationCriteria(this.customActivationProperties);
if (this.config.minDist !== undefined) {
this.minDistSq = this.config.minDist * this.config.minDist;
} else if (this.hasCustomActivationCriteria) {
this.minDistSq = Number.MAX_SAFE_INTEGER;
}
if (this.config.minPointers !== undefined) {
this.minPointers = this.config.minPointers;
}
if (this.config.maxPointers !== undefined) {
this.maxPointers = this.config.maxPointers;
}
if (this.config.minVelocity !== undefined) {
this.minVelocityX = this.config.minVelocity;
this.minVelocityY = this.config.minVelocity;
}
if (this.config.minVelocityX !== undefined) {
this.minVelocityX = this.config.minVelocityX;
}
if (this.config.minVelocityY !== undefined) {
this.minVelocityY = this.config.minVelocityY;
}
if (this.config.activateAfterLongPress !== undefined) {
this.activateAfterLongPress = this.config.activateAfterLongPress;
}
if (this.config.activeOffsetXStart !== undefined) {
this.activeOffsetXStart = this.config.activeOffsetXStart;
if (this.config.activeOffsetXEnd === undefined) {
this.activeOffsetXEnd = Number.MAX_SAFE_INTEGER;
}
}
if (this.config.activeOffsetXEnd !== undefined) {
this.activeOffsetXEnd = this.config.activeOffsetXEnd;
if (this.config.activeOffsetXStart === undefined) {
this.activeOffsetXStart = Number.MIN_SAFE_INTEGER;
}
}
if (this.config.failOffsetXStart !== undefined) {
this.failOffsetXStart = this.config.failOffsetXStart;
if (this.config.failOffsetXEnd === undefined) {
this.failOffsetXEnd = Number.MAX_SAFE_INTEGER;
}
}
if (this.config.failOffsetXEnd !== undefined) {
this.failOffsetXEnd = this.config.failOffsetXEnd;
if (this.config.failOffsetXStart === undefined) {
this.failOffsetXStart = Number.MIN_SAFE_INTEGER;
}
}
if (this.config.activeOffsetYStart !== undefined) {
this.activeOffsetYStart = this.config.activeOffsetYStart;
if (this.config.activeOffsetYEnd === undefined) {
this.activeOffsetYEnd = Number.MAX_SAFE_INTEGER;
}
}
if (this.config.activeOffsetYEnd !== undefined) {
this.activeOffsetYEnd = this.config.activeOffsetYEnd;
if (this.config.activeOffsetYStart === undefined) {
this.activeOffsetYStart = Number.MIN_SAFE_INTEGER;
}
}
if (this.config.failOffsetYStart !== undefined) {
this.failOffsetYStart = this.config.failOffsetYStart;
if (this.config.failOffsetYEnd === undefined) {
this.failOffsetYEnd = Number.MAX_SAFE_INTEGER;
}
}
if (this.config.failOffsetYEnd !== undefined) {
this.failOffsetYEnd = this.config.failOffsetYEnd;
if (this.config.failOffsetYStart === undefined) {
this.failOffsetYStart = Number.MIN_SAFE_INTEGER;
}
}
if (this.config.enableTrackpadTwoFingerGesture !== undefined) {
this.enableTrackpadTwoFingerGesture =
this.config.enableTrackpadTwoFingerGesture;
}
}
protected resetConfig(): void {
super.resetConfig();
this.activeOffsetXStart = -Number.MAX_SAFE_INTEGER;
this.activeOffsetXEnd = Number.MIN_SAFE_INTEGER;
this.failOffsetXStart = Number.MIN_SAFE_INTEGER;
this.failOffsetXEnd = Number.MAX_SAFE_INTEGER;
this.activeOffsetYStart = Number.MAX_SAFE_INTEGER;
this.activeOffsetYEnd = Number.MIN_SAFE_INTEGER;
this.failOffsetYStart = Number.MIN_SAFE_INTEGER;
this.failOffsetYEnd = Number.MAX_SAFE_INTEGER;
this.minVelocityX = Number.MAX_SAFE_INTEGER;
this.minVelocityY = Number.MAX_SAFE_INTEGER;
this.minVelocitySq = Number.MAX_SAFE_INTEGER;
this.minDistSq = DEFAULT_MIN_DIST_SQ;
this.minPointers = DEFAULT_MIN_POINTERS;
this.maxPointers = DEFAULT_MAX_POINTERS;
this.activateAfterLongPress = 0;
}
protected transformNativeEvent() {
const translationX: number = this.getTranslationX();
const translationY: number = this.getTranslationY();
return {
...super.transformNativeEvent(),
translationX: isNaN(translationX) ? 0 : translationX,
translationY: isNaN(translationY) ? 0 : translationY,
velocityX: this.velocityX,
velocityY: this.velocityY,
stylusData: this.stylusData,
};
}
private getTranslationX(): number {
return this.lastX - this.startX + this.offsetX;
}
private getTranslationY(): number {
return this.lastY - this.startY + this.offsetY;
}
private clearActivationTimeout(): void {
clearTimeout(this.activationTimeout);
}
private updateLastCoords() {
const { x, y } = this.tracker.getAbsoluteCoordsAverage();
this.lastX = x;
this.lastY = y;
}
private updateVelocity(pointerId: number) {
const velocities = this.tracker.getVelocity(pointerId);
this.velocityX = velocities?.x ?? 0;
this.velocityY = velocities?.y ?? 0;
}
// Events Handling
protected onPointerDown(event: AdaptedEvent): void {
if (!this.isButtonInConfig(event.button)) {
return;
}
this.tracker.addToTracker(event);
this.stylusData = event.stylusData;
super.onPointerDown(event);
this.updateLastCoords();
this.startX = this.lastX;
this.startY = this.lastY;
this.tryBegin(event);
this.checkBegan();
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerAdd(event);
this.tryBegin(event);
this.offsetX += this.lastX - this.startX;
this.offsetY += this.lastY - this.startY;
this.updateLastCoords();
this.startX = this.lastX;
this.startY = this.lastY;
if (this.tracker.trackedPointersCount > this.maxPointers) {
if (this.state === State.ACTIVE) {
this.cancel();
} else {
this.fail();
}
} else {
this.checkBegan();
}
}
protected onPointerUp(event: AdaptedEvent): void {
this.stylusData = event.stylusData;
super.onPointerUp(event);
if (this.state === State.ACTIVE) {
const lastCoords = this.tracker.getAbsoluteCoordsAverage();
this.lastX = lastCoords.x;
this.lastY = lastCoords.y;
}
this.tracker.removeFromTracker(event.pointerId);
if (this.tracker.trackedPointersCount === 0) {
this.clearActivationTimeout();
}
if (this.state === State.ACTIVE) {
this.end();
} else {
this.resetProgress();
this.fail();
}
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.tracker.removeFromTracker(event.pointerId);
this.offsetX += this.lastX - this.startX;
this.offsetY += this.lastY - this.startY;
this.updateLastCoords();
this.startX = this.lastX;
this.startY = this.lastY;
if (
!(
this.state === State.ACTIVE &&
this.tracker.trackedPointersCount < this.minPointers
)
) {
this.checkBegan();
}
}
protected onPointerMove(event: AdaptedEvent): void {
this.tracker.track(event);
this.stylusData = event.stylusData;
this.updateLastCoords();
this.updateVelocity(event.pointerId);
this.checkBegan();
super.onPointerMove(event);
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
if (this.shouldCancelWhenOutside) {
return;
}
this.tracker.track(event);
this.stylusData = event.stylusData;
this.updateLastCoords();
this.updateVelocity(event.pointerId);
this.checkBegan();
if (this.state === State.ACTIVE) {
super.onPointerOutOfBounds(event);
}
}
private scheduleWheelEnd(event: AdaptedEvent) {
clearTimeout(this.endWheelTimeout);
this.endWheelTimeout = setTimeout(() => {
if (this.state === State.ACTIVE) {
this.end();
this.tracker.removeFromTracker(event.pointerId);
this.state = State.UNDETERMINED;
}
this.wheelDevice = WheelDevice.UNDETERMINED;
}, 30);
}
protected onWheel(event: AdaptedEvent): void {
if (
this.wheelDevice === WheelDevice.MOUSE ||
!this.enableTrackpadTwoFingerGesture
) {
return;
}
if (this.state === State.UNDETERMINED) {
this.wheelDevice =
event.wheelDeltaY! % 120 !== 0
? WheelDevice.TOUCHPAD
: WheelDevice.MOUSE;
if (this.wheelDevice === WheelDevice.MOUSE) {
this.scheduleWheelEnd(event);
return;
}
this.tracker.addToTracker(event);
this.updateLastCoords();
this.startX = this.lastX;
this.startY = this.lastY;
this.begin();
this.activate();
}
this.tracker.track(event);
this.updateLastCoords();
this.updateVelocity(event.pointerId);
this.tryToSendMoveEvent(false, event);
this.scheduleWheelEnd(event);
}
private shouldActivate(): boolean {
const dx: number = this.getTranslationX();
if (
this.activeOffsetXStart !== Number.MAX_SAFE_INTEGER &&
dx < this.activeOffsetXStart
) {
return true;
}
if (
this.activeOffsetXEnd !== Number.MIN_SAFE_INTEGER &&
dx > this.activeOffsetXEnd
) {
return true;
}
const dy: number = this.getTranslationY();
if (
this.activeOffsetYStart !== Number.MAX_SAFE_INTEGER &&
dy < this.activeOffsetYStart
) {
return true;
}
if (
this.activeOffsetYEnd !== Number.MIN_SAFE_INTEGER &&
dy > this.activeOffsetYEnd
) {
return true;
}
const distanceSq: number = dx * dx + dy * dy;
if (
this.minDistSq !== Number.MAX_SAFE_INTEGER &&
distanceSq >= this.minDistSq
) {
return true;
}
const vx: number = this.velocityX;
if (
this.minVelocityX !== Number.MAX_SAFE_INTEGER &&
((this.minVelocityX < 0 && vx <= this.minVelocityX) ||
(this.minVelocityX >= 0 && this.minVelocityX <= vx))
) {
return true;
}
const vy: number = this.velocityY;
if (
this.minVelocityY !== Number.MAX_SAFE_INTEGER &&
((this.minVelocityY < 0 && vy <= this.minVelocityY) ||
(this.minVelocityY >= 0 && this.minVelocityY <= vy))
) {
return true;
}
const velocitySq: number = vx * vx + vy * vy;
return (
this.minVelocitySq !== Number.MAX_SAFE_INTEGER &&
velocitySq >= this.minVelocitySq
);
}
private shouldFail(): boolean {
const dx: number = this.getTranslationX();
const dy: number = this.getTranslationY();
const distanceSq = dx * dx + dy * dy;
if (this.activateAfterLongPress > 0 && distanceSq > DEFAULT_MIN_DIST_SQ) {
this.clearActivationTimeout();
return true;
}
if (
this.failOffsetXStart !== Number.MIN_SAFE_INTEGER &&
dx < this.failOffsetXStart
) {
return true;
}
if (
this.failOffsetXEnd !== Number.MAX_SAFE_INTEGER &&
dx > this.failOffsetXEnd
) {
return true;
}
if (
this.failOffsetYStart !== Number.MIN_SAFE_INTEGER &&
dy < this.failOffsetYStart
) {
return true;
}
return (
this.failOffsetYEnd !== Number.MAX_SAFE_INTEGER &&
dy > this.failOffsetYEnd
);
}
private tryBegin(event: AdaptedEvent): void {
if (
this.state === State.UNDETERMINED &&
this.tracker.trackedPointersCount >= this.minPointers
) {
this.resetProgress();
this.offsetX = 0;
this.offsetY = 0;
this.velocityX = 0;
this.velocityY = 0;
this.begin();
if (this.activateAfterLongPress > 0) {
this.activationTimeout = setTimeout(() => {
this.activate();
}, this.activateAfterLongPress);
}
} else {
this.updateVelocity(event.pointerId);
}
}
private checkBegan(): void {
if (this.state === State.BEGAN) {
if (this.shouldFail()) {
this.fail();
} else if (this.shouldActivate()) {
this.activate();
}
}
}
public activate(force = false): void {
if (this.state !== State.ACTIVE) {
this.resetProgress();
}
super.activate(force);
}
protected onCancel(): void {
this.clearActivationTimeout();
}
protected onReset(): void {
this.clearActivationTimeout();
}
protected resetProgress(): void {
if (this.state === State.ACTIVE) {
return;
}
this.startX = this.lastX;
this.startY = this.lastY;
}
}
@@ -0,0 +1,152 @@
import { State } from '../../State';
import { DEFAULT_TOUCH_SLOP } from '../constants';
import { AdaptedEvent } from '../interfaces';
import GestureHandler from './GestureHandler';
import ScaleGestureDetector, {
ScaleGestureListener,
} from '../detectors/ScaleGestureDetector';
export default class PinchGestureHandler extends GestureHandler {
private scale = 1;
private velocity = 0;
private startingSpan = 0;
private spanSlop = DEFAULT_TOUCH_SLOP;
private scaleDetectorListener: ScaleGestureListener = {
onScaleBegin: (detector: ScaleGestureDetector): boolean => {
this.startingSpan = detector.currentSpan;
return true;
},
onScale: (detector: ScaleGestureDetector): boolean => {
const prevScaleFactor: number = this.scale;
this.scale *= detector.calculateScaleFactor(
this.tracker.trackedPointersCount
);
const delta = detector.timeDelta;
if (delta > 0) {
this.velocity = (this.scale - prevScaleFactor) / delta;
}
if (
Math.abs(this.startingSpan - detector.currentSpan) >= this.spanSlop &&
this.state === State.BEGAN
) {
this.activate();
}
return true;
},
onScaleEnd: (
_detector: ScaleGestureDetector
// eslint-disable-next-line @typescript-eslint/no-empty-function
): void => {},
};
private scaleGestureDetector: ScaleGestureDetector = new ScaleGestureDetector(
this.scaleDetectorListener
);
public init(ref: number, propsRef: React.RefObject<unknown>) {
super.init(ref, propsRef);
this.shouldCancelWhenOutside = false;
}
protected transformNativeEvent() {
return {
focalX: this.scaleGestureDetector.focusX,
focalY: this.scaleGestureDetector.focusY,
velocity: this.velocity,
scale: this.scale,
};
}
protected onPointerDown(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerDown(event);
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerAdd(event);
this.tryBegin();
this.scaleGestureDetector.onTouchEvent(event, this.tracker);
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.tracker.removeFromTracker(event.pointerId);
if (this.state !== State.ACTIVE) {
return;
}
this.scaleGestureDetector.onTouchEvent(event, this.tracker);
if (this.state === State.ACTIVE) {
this.end();
} else {
this.fail();
}
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.scaleGestureDetector.onTouchEvent(event, this.tracker);
this.tracker.removeFromTracker(event.pointerId);
if (this.state === State.ACTIVE && this.tracker.trackedPointersCount < 2) {
this.end();
}
}
protected onPointerMove(event: AdaptedEvent): void {
if (this.tracker.trackedPointersCount < 2) {
return;
}
this.tracker.track(event);
this.scaleGestureDetector.onTouchEvent(event, this.tracker);
super.onPointerMove(event);
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
if (this.tracker.trackedPointersCount < 2) {
return;
}
this.tracker.track(event);
this.scaleGestureDetector.onTouchEvent(event, this.tracker);
super.onPointerOutOfBounds(event);
}
private tryBegin(): void {
if (this.state !== State.UNDETERMINED) {
return;
}
this.resetProgress();
this.begin();
}
public activate(force?: boolean): void {
if (this.state !== State.ACTIVE) {
this.resetProgress();
}
super.activate(force);
}
protected onReset(): void {
this.resetProgress();
}
protected resetProgress(): void {
if (this.state === State.ACTIVE) {
return;
}
this.velocity = 0;
this.scale = 1;
}
}
@@ -0,0 +1,166 @@
import { State } from '../../State';
import { AdaptedEvent } from '../interfaces';
import GestureHandler from './GestureHandler';
import RotationGestureDetector, {
RotationGestureListener,
} from '../detectors/RotationGestureDetector';
const ROTATION_RECOGNITION_THRESHOLD = Math.PI / 36;
export default class RotationGestureHandler extends GestureHandler {
private rotation = 0;
private velocity = 0;
private cachedAnchorX = 0;
private cachedAnchorY = 0;
private rotationGestureListener: RotationGestureListener = {
onRotationBegin: (_detector: RotationGestureDetector): boolean => true,
onRotation: (detector: RotationGestureDetector): boolean => {
const previousRotation: number = this.rotation;
this.rotation += detector.rotation;
const delta = detector.timeDelta;
if (delta > 0) {
this.velocity = (this.rotation - previousRotation) / delta;
}
if (
Math.abs(this.rotation) >= ROTATION_RECOGNITION_THRESHOLD &&
this.state === State.BEGAN
) {
this.activate();
}
return true;
},
onRotationEnd: (_detector: RotationGestureDetector): void => {
this.end();
},
};
private rotationGestureDetector: RotationGestureDetector =
new RotationGestureDetector(this.rotationGestureListener);
public init(ref: number, propsRef: React.RefObject<unknown>): void {
super.init(ref, propsRef);
this.shouldCancelWhenOutside = false;
}
protected transformNativeEvent() {
return {
rotation: this.rotation ? this.rotation : 0,
anchorX: this.getAnchorX(),
anchorY: this.getAnchorY(),
velocity: this.velocity ? this.velocity : 0,
};
}
public getAnchorX(): number {
const anchorX = this.rotationGestureDetector.anchorX;
return anchorX ? anchorX : this.cachedAnchorX;
}
public getAnchorY(): number {
const anchorY = this.rotationGestureDetector.anchorY;
return anchorY ? anchorY : this.cachedAnchorY;
}
protected onPointerDown(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerDown(event);
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
this.tracker.addToTracker(event);
super.onPointerAdd(event);
this.tryBegin();
this.rotationGestureDetector.onTouchEvent(event, this.tracker);
}
protected onPointerMove(event: AdaptedEvent): void {
if (this.tracker.trackedPointersCount < 2) {
return;
}
if (this.getAnchorX()) {
this.cachedAnchorX = this.getAnchorX();
}
if (this.getAnchorY()) {
this.cachedAnchorY = this.getAnchorY();
}
this.tracker.track(event);
this.rotationGestureDetector.onTouchEvent(event, this.tracker);
super.onPointerMove(event);
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
if (this.tracker.trackedPointersCount < 2) {
return;
}
if (this.getAnchorX()) {
this.cachedAnchorX = this.getAnchorX();
}
if (this.getAnchorY()) {
this.cachedAnchorY = this.getAnchorY();
}
this.tracker.track(event);
this.rotationGestureDetector.onTouchEvent(event, this.tracker);
super.onPointerOutOfBounds(event);
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.tracker.removeFromTracker(event.pointerId);
this.rotationGestureDetector.onTouchEvent(event, this.tracker);
if (this.state !== State.ACTIVE) {
return;
}
if (this.state === State.ACTIVE) {
this.end();
} else {
this.fail();
}
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.rotationGestureDetector.onTouchEvent(event, this.tracker);
this.tracker.removeFromTracker(event.pointerId);
}
protected tryBegin(): void {
if (this.state !== State.UNDETERMINED) {
return;
}
this.begin();
}
protected onReset(): void {
if (this.state === State.ACTIVE) {
return;
}
this.rotation = 0;
this.velocity = 0;
this.rotationGestureDetector.reset();
}
}
@@ -0,0 +1,274 @@
import { State } from '../../State';
import { AdaptedEvent, Config, EventTypes } from '../interfaces';
import GestureHandler from './GestureHandler';
const DEFAULT_MAX_DURATION_MS = 500;
const DEFAULT_MAX_DELAY_MS = 500;
const DEFAULT_NUMBER_OF_TAPS = 1;
const DEFAULT_MIN_NUMBER_OF_POINTERS = 1;
export default class TapGestureHandler extends GestureHandler {
private maxDeltaX = Number.MIN_SAFE_INTEGER;
private maxDeltaY = Number.MIN_SAFE_INTEGER;
private maxDistSq = Number.MIN_SAFE_INTEGER;
private maxDurationMs = DEFAULT_MAX_DURATION_MS;
private maxDelayMs = DEFAULT_MAX_DELAY_MS;
private numberOfTaps = DEFAULT_NUMBER_OF_TAPS;
private minNumberOfPointers = DEFAULT_MIN_NUMBER_OF_POINTERS;
private currentMaxNumberOfPointers = 1;
private startX = 0;
private startY = 0;
private offsetX = 0;
private offsetY = 0;
private lastX = 0;
private lastY = 0;
private waitTimeout: number | undefined;
private delayTimeout: number | undefined;
private tapsSoFar = 0;
public updateGestureConfig({ enabled = true, ...props }: Config): void {
super.updateGestureConfig({ enabled: enabled, ...props });
if (this.config.numberOfTaps !== undefined) {
this.numberOfTaps = this.config.numberOfTaps;
}
if (this.config.maxDurationMs !== undefined) {
this.maxDurationMs = this.config.maxDurationMs;
}
if (this.config.maxDelayMs !== undefined) {
this.maxDelayMs = this.config.maxDelayMs;
}
if (this.config.maxDeltaX !== undefined) {
this.maxDeltaX = this.config.maxDeltaX;
}
if (this.config.maxDeltaY !== undefined) {
this.maxDeltaY = this.config.maxDeltaY;
}
if (this.config.maxDist !== undefined) {
this.maxDistSq = this.config.maxDist * this.config.maxDist;
}
if (this.config.minPointers !== undefined) {
this.minNumberOfPointers = this.config.minPointers;
}
}
protected resetConfig(): void {
super.resetConfig();
this.maxDeltaX = Number.MIN_SAFE_INTEGER;
this.maxDeltaY = Number.MIN_SAFE_INTEGER;
this.maxDistSq = Number.MIN_SAFE_INTEGER;
this.maxDurationMs = DEFAULT_MAX_DURATION_MS;
this.maxDelayMs = DEFAULT_MAX_DELAY_MS;
this.numberOfTaps = DEFAULT_NUMBER_OF_TAPS;
this.minNumberOfPointers = DEFAULT_MIN_NUMBER_OF_POINTERS;
}
private clearTimeouts(): void {
clearTimeout(this.waitTimeout);
clearTimeout(this.delayTimeout);
}
private startTap(): void {
this.clearTimeouts();
this.waitTimeout = setTimeout(() => this.fail(), this.maxDurationMs);
}
private endTap(): void {
this.clearTimeouts();
if (
++this.tapsSoFar === this.numberOfTaps &&
this.currentMaxNumberOfPointers >= this.minNumberOfPointers
) {
this.activate();
} else {
this.delayTimeout = setTimeout(() => this.fail(), this.maxDelayMs);
}
}
private updateLastCoords() {
const { x, y } = this.tracker.getAbsoluteCoordsAverage();
this.lastX = x;
this.lastY = y;
}
// Handling Events
protected onPointerDown(event: AdaptedEvent): void {
if (!this.isButtonInConfig(event.button)) {
return;
}
this.tracker.addToTracker(event);
super.onPointerDown(event);
this.trySettingPosition(event);
this.startX = event.x;
this.startY = event.y;
this.lastX = event.x;
this.lastY = event.y;
this.updateState(event);
this.tryToSendTouchEvent(event);
}
protected onPointerAdd(event: AdaptedEvent): void {
super.onPointerAdd(event);
this.tracker.addToTracker(event);
this.trySettingPosition(event);
this.offsetX += this.lastX - this.startX;
this.offsetY += this.lastY - this.startY;
this.updateLastCoords();
this.startX = this.lastX;
this.startY = this.lastY;
this.updateState(event);
}
protected onPointerUp(event: AdaptedEvent): void {
super.onPointerUp(event);
this.updateLastCoords();
this.tracker.removeFromTracker(event.pointerId);
this.updateState(event);
}
protected onPointerRemove(event: AdaptedEvent): void {
super.onPointerRemove(event);
this.tracker.removeFromTracker(event.pointerId);
this.offsetX += this.lastX - this.startX;
this.offsetY += this.lastY = this.startY;
this.updateLastCoords();
this.startX = this.lastX;
this.startY = this.lastY;
this.updateState(event);
}
protected onPointerMove(event: AdaptedEvent): void {
this.trySettingPosition(event);
this.tracker.track(event);
this.updateLastCoords();
this.updateState(event);
super.onPointerMove(event);
}
protected onPointerOutOfBounds(event: AdaptedEvent): void {
this.trySettingPosition(event);
this.tracker.track(event);
this.updateLastCoords();
this.updateState(event);
super.onPointerOutOfBounds(event);
}
private updateState(event: AdaptedEvent): void {
if (this.currentMaxNumberOfPointers < this.tracker.trackedPointersCount) {
this.currentMaxNumberOfPointers = this.tracker.trackedPointersCount;
}
if (this.shouldFail()) {
this.fail();
return;
}
switch (this.state) {
case State.UNDETERMINED:
if (event.eventType === EventTypes.DOWN) {
this.begin();
}
this.startTap();
break;
case State.BEGAN:
if (event.eventType === EventTypes.UP) {
this.endTap();
}
if (event.eventType === EventTypes.DOWN) {
this.startTap();
}
break;
default:
break;
}
}
private trySettingPosition(event: AdaptedEvent): void {
if (this.state !== State.UNDETERMINED) {
return;
}
this.offsetX = 0;
this.offsetY = 0;
this.startX = event.x;
this.startY = event.y;
}
private shouldFail(): boolean {
const dx = this.lastX - this.startX + this.offsetX;
if (
this.maxDeltaX !== Number.MIN_SAFE_INTEGER &&
Math.abs(dx) > this.maxDeltaX
) {
return true;
}
const dy = this.lastY - this.startY + this.offsetY;
if (
this.maxDeltaY !== Number.MIN_SAFE_INTEGER &&
Math.abs(dy) > this.maxDeltaY
) {
return true;
}
const distSq = dy * dy + dx * dx;
return (
this.maxDistSq !== Number.MIN_SAFE_INTEGER && distSq > this.maxDistSq
);
}
public activate(): void {
super.activate();
this.end();
}
protected onCancel(): void {
this.resetProgress();
this.clearTimeouts();
}
protected resetProgress(): void {
this.clearTimeouts();
this.tapsSoFar = 0;
this.currentMaxNumberOfPointers = 0;
}
}
@@ -0,0 +1,190 @@
import {
UserSelect,
ActiveCursor,
MouseButton,
TouchAction,
} from '../handlers/gestureHandlerCommon';
import { Directions } from '../Directions';
import { State } from '../State';
import { PointerType } from '../PointerType';
export interface HitSlop {
left?: number;
right?: number;
top?: number;
bottom?: number;
horizontal?: number;
vertical?: number;
width?: number;
height?: number;
}
export interface Handler {
handlerTag: number;
}
type ConfigArgs =
| number
| boolean
| HitSlop
| UserSelect
| TouchAction
| ActiveCursor
| Directions
| Handler[]
| null
| undefined;
export interface Config extends Record<string, ConfigArgs> {
enabled: boolean;
simultaneousHandlers?: Handler[] | null;
waitFor?: Handler[] | null;
blocksHandlers?: Handler[] | null;
hitSlop?: HitSlop;
shouldCancelWhenOutside?: boolean;
userSelect?: UserSelect;
activeCursor?: ActiveCursor;
mouseButton?: MouseButton;
enableContextMenu?: boolean;
touchAction?: TouchAction;
manualActivation?: boolean;
activateAfterLongPress?: number;
failOffsetXStart?: number;
failOffsetYStart?: number;
failOffsetXEnd?: number;
failOffsetYEnd?: number;
activeOffsetXStart?: number;
activeOffsetXEnd?: number;
activeOffsetYStart?: number;
activeOffsetYEnd?: number;
minPointers?: number;
maxPointers?: number;
minDist?: number;
minDistSq?: number;
minVelocity?: number;
minVelocityX?: number;
minVelocityY?: number;
minVelocitySq?: number;
maxDist?: number;
maxDistSq?: number;
numberOfPointers?: number;
minDurationMs?: number;
numberOfTaps?: number;
maxDurationMs?: number;
maxDelayMs?: number;
maxDeltaX?: number;
maxDeltaY?: number;
shouldActivateOnStart?: boolean;
disallowInterruption?: boolean;
direction?: Directions;
enableTrackpadTwoFingerGesture?: boolean;
}
type NativeEventArgs = number | State | boolean | undefined;
interface NativeEvent extends Record<string, NativeEventArgs> {
numberOfPointers: number;
state: State;
pointerInside: boolean | undefined;
handlerTag: number;
target: number;
oldState?: State;
pointerType: PointerType;
}
export interface Point {
x: number;
y: number;
}
export interface PointerData {
id: number;
x: number;
y: number;
absoluteX: number;
absoluteY: number;
}
type TouchNativeArgs = number | State | TouchEventType | PointerData[];
interface NativeTouchEvent extends Record<string, TouchNativeArgs> {
handlerTag: number;
state: State;
eventType: TouchEventType;
changedTouches: PointerData[];
allTouches: PointerData[];
numberOfTouches: number;
pointerType: PointerType;
}
export interface ResultEvent extends Record<string, NativeEvent | number> {
nativeEvent: NativeEvent;
timeStamp: number;
}
export interface ResultTouchEvent
extends Record<string, NativeTouchEvent | number> {
nativeEvent: NativeTouchEvent;
timeStamp: number;
}
export interface PropsRef {
onGestureHandlerEvent: () => void;
onGestureHandlerStateChange: () => void;
}
export interface StylusData {
tiltX: number;
tiltY: number;
azimuthAngle: number;
altitudeAngle: number;
pressure: number;
}
export interface AdaptedEvent {
x: number;
y: number;
offsetX: number;
offsetY: number;
pointerId: number;
eventType: EventTypes;
pointerType: PointerType;
time: number;
button?: MouseButton;
stylusData?: StylusData;
wheelDeltaY?: number;
}
export enum EventTypes {
DOWN,
ADDITIONAL_POINTER_DOWN,
UP,
ADDITIONAL_POINTER_UP,
MOVE,
ENTER,
LEAVE,
CANCEL,
}
export enum TouchEventType {
UNDETERMINED,
DOWN,
MOVE,
UP,
CANCELLED,
}
export enum WheelDevice {
UNDETERMINED,
MOUSE,
TOUCHPAD,
}
export type GestureHandlerRef = {
viewTag: GestureHandlerRef;
current: HTMLElement;
};
export type SVGRef = {
elementRef: { current: SVGElement };
};
@@ -0,0 +1,42 @@
export default class CircularBuffer<T> {
private capacity: number;
private buffer: T[];
private index: number;
private _size: number;
constructor(size: number) {
this.capacity = size;
this.buffer = new Array<T>(size);
this.index = 0;
this._size = 0;
}
public push(element: T): void {
this.buffer[this.index] = element;
this.index = (this.index + 1) % this.capacity;
this._size = Math.min(this.size + 1, this.capacity);
}
public get(at: number): T {
if (this._size === this.capacity) {
let index = (this.index + at) % this.capacity;
if (index < 0) {
index += this.capacity;
}
return this.buffer[index];
} else {
return this.buffer[at];
}
}
public clear(): void {
this.buffer = new Array<T>(this.capacity);
this.index = 0;
this._size = 0;
}
public get size() {
return this._size;
}
}
@@ -0,0 +1,108 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { AdaptedEvent, EventTypes } from '../interfaces';
type PointerEventCallback = (event: AdaptedEvent) => void;
export default abstract class EventManager<T> {
protected readonly view: T;
protected pointersInBounds: number[] = [];
protected activePointersCounter: number;
constructor(view: T) {
this.view = view;
this.activePointersCounter = 0;
}
public abstract registerListeners(): void;
public abstract unregisterListeners(): void;
protected abstract mapEvent(
event: Event,
eventType: EventTypes
): AdaptedEvent;
protected onPointerDown(_event: AdaptedEvent): void {}
protected onPointerAdd(_event: AdaptedEvent): void {}
protected onPointerUp(_event: AdaptedEvent): void {}
protected onPointerRemove(_event: AdaptedEvent): void {}
protected onPointerMove(_event: AdaptedEvent): void {}
protected onPointerLeave(_event: AdaptedEvent): void {} // Called only when pointer is pressed (or touching)
protected onPointerEnter(_event: AdaptedEvent): void {} // Called only when pointer is pressed (or touching)
protected onPointerCancel(_event: AdaptedEvent): void {
// When pointer cancel is triggered and there are more pointers on the view, only one pointer is cancelled
// Because we want all pointers to be cancelled by that event, we are doing it manually by reseting handler and changing activePointersCounter to 0
// Events that correspond to removing the pointer (pointerup, touchend) have condition, that they don't perform any action when activePointersCounter
// is equal to 0. This prevents counter from going to negative values, when pointers are removed from view after one of them has been cancelled
}
protected onPointerOutOfBounds(_event: AdaptedEvent): void {}
protected onPointerMoveOver(_event: AdaptedEvent): void {}
protected onPointerMoveOut(_event: AdaptedEvent): void {}
protected onWheel(_event: AdaptedEvent): void {}
public setOnPointerDown(callback: PointerEventCallback): void {
this.onPointerDown = callback;
}
public setOnPointerAdd(callback: PointerEventCallback): void {
this.onPointerAdd = callback;
}
public setOnPointerUp(callback: PointerEventCallback): void {
this.onPointerUp = callback;
}
public setOnPointerRemove(callback: PointerEventCallback): void {
this.onPointerRemove = callback;
}
public setOnPointerMove(callback: PointerEventCallback): void {
this.onPointerMove = callback;
}
public setOnPointerLeave(callback: PointerEventCallback): void {
this.onPointerLeave = callback;
}
public setOnPointerEnter(callback: PointerEventCallback): void {
this.onPointerEnter = callback;
}
public setOnPointerCancel(callback: PointerEventCallback): void {
this.onPointerCancel = callback;
}
public setOnPointerOutOfBounds(callback: PointerEventCallback): void {
this.onPointerOutOfBounds = callback;
}
public setOnPointerMoveOver(callback: PointerEventCallback): void {
this.onPointerMoveOver = callback;
}
public setOnPointerMoveOut(callback: PointerEventCallback): void {
this.onPointerMoveOut = callback;
}
public setOnWheel(callback: PointerEventCallback): void {
this.onWheel = callback;
}
protected markAsInBounds(pointerId: number): void {
if (this.pointersInBounds.indexOf(pointerId) >= 0) {
return;
}
this.pointersInBounds.push(pointerId);
}
protected markAsOutOfBounds(pointerId: number): void {
const index: number = this.pointersInBounds.indexOf(pointerId);
if (index < 0) {
return;
}
this.pointersInBounds.splice(index, 1);
}
public resetManager(): void {
// Reseting activePointersCounter is necessary to make gestures such as pinch work properly
// There are gestures that end when there is still one active pointer (like pinch/rotation)
// When these gestures end, they are reset, but they still receive events from pointer that is active
// This causes trouble, since only onPointerDown registers gesture in orchestrator, and while gestures receive
// Events from active pointer after they finished, next pointerdown event will be registered as additional pointer, not the first one
// This casues trouble like gestures getting stuck in END state, even though they should have gone to UNDETERMINED
this.activePointersCounter = 0;
this.pointersInBounds = [];
}
}
@@ -0,0 +1,26 @@
import { Config } from '../interfaces';
export interface MeasureResult {
pageX: number;
pageY: number;
width: number;
height: number;
}
export interface GestureHandlerDelegate<TComponent, THandler> {
view: TComponent;
init(viewRef: number, handler: THandler): void;
isPointerInBounds({ x, y }: { x: number; y: number }): boolean;
measureView(): MeasureResult;
reset(): void;
onBegin(): void;
onActivate(): void;
onEnd(): void;
onCancel(): void;
onFail(): void;
onEnabledChange(enabled: boolean): void;
destroy(config: Config): void;
}
@@ -0,0 +1,392 @@
import { PointerType } from '../../PointerType';
import { State } from '../../State';
import type IGestureHandler from '../handlers/IGestureHandler';
import PointerTracker from './PointerTracker';
export default class GestureHandlerOrchestrator {
private static _instance: GestureHandlerOrchestrator;
private gestureHandlers: IGestureHandler[] = [];
private awaitingHandlers: IGestureHandler[] = [];
private awaitingHandlersTags: Set<number> = new Set();
private handlingChangeSemaphore = 0;
private activationIndex = 0;
// Private beacuse of Singleton
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {}
private scheduleFinishedHandlersCleanup(): void {
if (this.handlingChangeSemaphore === 0) {
this.cleanupFinishedHandlers();
}
}
private cleanHandler(handler: IGestureHandler): void {
handler.reset();
handler.active = false;
handler.awaiting = false;
handler.activationIndex = Number.MAX_VALUE;
}
public removeHandlerFromOrchestrator(handler: IGestureHandler): void {
const indexInGestureHandlers = this.gestureHandlers.indexOf(handler);
const indexInAwaitingHandlers = this.awaitingHandlers.indexOf(handler);
if (indexInGestureHandlers >= 0) {
this.gestureHandlers.splice(indexInGestureHandlers, 1);
}
if (indexInAwaitingHandlers >= 0) {
this.awaitingHandlers.splice(indexInAwaitingHandlers, 1);
this.awaitingHandlersTags.delete(handler.handlerTag);
}
}
private cleanupFinishedHandlers(): void {
const handlersToRemove = new Set<IGestureHandler>();
for (let i = this.gestureHandlers.length - 1; i >= 0; --i) {
const handler = this.gestureHandlers[i];
if (this.isFinished(handler.state) && !handler.awaiting) {
this.cleanHandler(handler);
handlersToRemove.add(handler);
}
}
this.gestureHandlers = this.gestureHandlers.filter(
(handler) => !handlersToRemove.has(handler)
);
}
private hasOtherHandlerToWaitFor(handler: IGestureHandler): boolean {
const hasToWaitFor = (otherHandler: IGestureHandler) => {
return (
!this.isFinished(otherHandler.state) &&
this.shouldHandlerWaitForOther(handler, otherHandler)
);
};
return this.gestureHandlers.some(hasToWaitFor);
}
private shouldBeCancelledByFinishedHandler(
handler: IGestureHandler
): boolean {
const shouldBeCancelled = (otherHandler: IGestureHandler) => {
return (
this.shouldHandlerWaitForOther(handler, otherHandler) &&
otherHandler.state === State.END
);
};
return this.gestureHandlers.some(shouldBeCancelled);
}
private tryActivate(handler: IGestureHandler): void {
if (this.shouldBeCancelledByFinishedHandler(handler)) {
handler.cancel();
return;
}
if (this.hasOtherHandlerToWaitFor(handler)) {
this.addAwaitingHandler(handler);
return;
}
const handlerState = handler.state;
if (handlerState === State.CANCELLED || handlerState === State.FAILED) {
return;
}
if (this.shouldActivate(handler)) {
this.makeActive(handler);
return;
}
if (handlerState === State.ACTIVE) {
handler.fail();
return;
}
if (handlerState === State.BEGAN) {
handler.cancel();
}
}
private shouldActivate(handler: IGestureHandler): boolean {
const shouldBeCancelledBy = (otherHandler: IGestureHandler) => {
return this.shouldHandlerBeCancelledBy(handler, otherHandler);
};
return !this.gestureHandlers.some(shouldBeCancelledBy);
}
private cleanupAwaitingHandlers(handler: IGestureHandler): void {
const shouldWait = (otherHandler: IGestureHandler) => {
return (
!otherHandler.awaiting &&
this.shouldHandlerWaitForOther(otherHandler, handler)
);
};
for (const otherHandler of this.awaitingHandlers) {
if (shouldWait(otherHandler)) {
this.cleanHandler(otherHandler);
this.awaitingHandlersTags.delete(otherHandler.handlerTag);
}
}
this.awaitingHandlers = this.awaitingHandlers.filter((otherHandler) =>
this.awaitingHandlersTags.has(otherHandler.handlerTag)
);
}
public onHandlerStateChange(
handler: IGestureHandler,
newState: State,
oldState: State,
sendIfDisabled?: boolean
): void {
if (!handler.enabled && !sendIfDisabled) {
return;
}
this.handlingChangeSemaphore += 1;
if (this.isFinished(newState)) {
for (const otherHandler of this.awaitingHandlers) {
if (
!this.shouldHandlerWaitForOther(otherHandler, handler) ||
!this.awaitingHandlersTags.has(otherHandler.handlerTag)
) {
continue;
}
if (newState !== State.END) {
this.tryActivate(otherHandler);
continue;
}
otherHandler.cancel();
if (otherHandler.state === State.END) {
// Handle edge case, where discrete gestures end immediately after activation thus
// their state is set to END and when the gesture they are waiting for activates they
// should be cancelled, however `cancel` was never sent as gestures were already in the END state.
// Send synthetic BEGAN -> CANCELLED to properly handle JS logic
otherHandler.sendEvent(State.CANCELLED, State.BEGAN);
}
otherHandler.awaiting = false;
}
}
if (newState === State.ACTIVE) {
this.tryActivate(handler);
} else if (oldState === State.ACTIVE || oldState === State.END) {
if (handler.active) {
handler.sendEvent(newState, oldState);
} else if (
oldState === State.ACTIVE &&
(newState === State.CANCELLED || newState === State.FAILED)
) {
handler.sendEvent(newState, State.BEGAN);
}
} else if (
oldState !== State.UNDETERMINED ||
newState !== State.CANCELLED
) {
handler.sendEvent(newState, oldState);
}
this.handlingChangeSemaphore -= 1;
this.scheduleFinishedHandlersCleanup();
if (!this.awaitingHandlers.includes(handler)) {
this.cleanupAwaitingHandlers(handler);
}
}
private makeActive(handler: IGestureHandler): void {
const currentState = handler.state;
handler.active = true;
handler.shouldResetProgress = true;
handler.activationIndex = this.activationIndex++;
for (let i = this.gestureHandlers.length - 1; i >= 0; --i) {
if (this.shouldHandlerBeCancelledBy(this.gestureHandlers[i], handler)) {
this.gestureHandlers[i].cancel();
}
}
for (const otherHandler of this.awaitingHandlers) {
if (this.shouldHandlerBeCancelledBy(otherHandler, handler)) {
otherHandler.awaiting = false;
}
}
handler.sendEvent(State.ACTIVE, State.BEGAN);
if (currentState !== State.ACTIVE) {
handler.sendEvent(State.END, State.ACTIVE);
if (currentState !== State.END) {
handler.sendEvent(State.UNDETERMINED, State.END);
}
}
if (!handler.awaiting) {
return;
}
handler.awaiting = false;
this.awaitingHandlers = this.awaitingHandlers.filter(
(otherHandler) => otherHandler !== handler
);
}
private addAwaitingHandler(handler: IGestureHandler): void {
if (this.awaitingHandlers.includes(handler)) {
return;
}
this.awaitingHandlers.push(handler);
this.awaitingHandlersTags.add(handler.handlerTag);
handler.awaiting = true;
handler.activationIndex = this.activationIndex++;
}
public recordHandlerIfNotPresent(handler: IGestureHandler): void {
if (this.gestureHandlers.includes(handler)) {
return;
}
this.gestureHandlers.push(handler);
handler.active = false;
handler.awaiting = false;
handler.activationIndex = Number.MAX_SAFE_INTEGER;
}
private shouldHandlerWaitForOther(
handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
return (
handler !== otherHandler &&
(handler.shouldWaitForHandlerFailure(otherHandler) ||
otherHandler.shouldRequireToWaitForFailure(handler))
);
}
private canRunSimultaneously(
gh1: IGestureHandler,
gh2: IGestureHandler
): boolean {
return (
gh1 === gh2 ||
gh1.shouldRecognizeSimultaneously(gh2) ||
gh2.shouldRecognizeSimultaneously(gh1)
);
}
private shouldHandlerBeCancelledBy(
handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
if (this.canRunSimultaneously(handler, otherHandler)) {
return false;
}
if (handler.awaiting || handler.state === State.ACTIVE) {
return handler.shouldBeCancelledByOther(otherHandler);
}
const handlerPointers: number[] = handler.getTrackedPointersID();
const otherPointers: number[] = otherHandler.getTrackedPointersID();
if (
!PointerTracker.shareCommonPointers(handlerPointers, otherPointers) &&
handler.delegate.view !== otherHandler.delegate.view
) {
return this.checkOverlap(handler, otherHandler);
}
return true;
}
private checkOverlap(
handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
// If handlers don't have common pointers, default return value is false.
// However, if at least on pointer overlaps with both handlers, we return true
// This solves issue in overlapping parents example
// TODO: Find better way to handle that issue, for example by activation order and handler cancelling
const isPointerWithinBothBounds = (pointer: number) => {
const point = handler.tracker.getLastAbsoluteCoords(pointer);
return (
point &&
handler.delegate.isPointerInBounds(point) &&
otherHandler.delegate.isPointerInBounds(point)
);
};
return handler.getTrackedPointersID().some(isPointerWithinBothBounds);
}
private isFinished(state: State): boolean {
return (
state === State.END || state === State.FAILED || state === State.CANCELLED
);
}
// This function is called when handler receives touchdown event
// If handler is using mouse or pen as a pointer and any handler receives touch event,
// mouse/pen event dissappears - it doesn't send onPointerCancel nor onPointerUp (and others)
// This became a problem because handler was left at active state without any signal to end or fail
// To handle this, when new touch event is received, we loop through active handlers and check which type of
// pointer they're using. If there are any handler with mouse/pen as a pointer, we cancel them
public cancelMouseAndPenGestures(currentHandler: IGestureHandler): void {
this.gestureHandlers.forEach((handler: IGestureHandler) => {
if (
handler.pointerType !== PointerType.MOUSE &&
handler.pointerType !== PointerType.STYLUS
) {
return;
}
if (handler !== currentHandler) {
handler.cancel();
} else {
// Handler that received touch event should have its pointer tracker reset
// This allows handler to smoothly change from mouse/pen to touch
// The drawback is, that when we try to use mouse/pen one more time, it doesn't send onPointerDown at the first time
// so it is required to click two times to get handler to work
//
// However, handler will receive manually created onPointerEnter that is triggered in EventManager in onPointerMove method.
// There may be possibility to use that fact to make handler respond properly to first mouse click
handler.tracker.resetTracker();
}
});
}
public static get instance(): GestureHandlerOrchestrator {
if (!GestureHandlerOrchestrator._instance) {
GestureHandlerOrchestrator._instance = new GestureHandlerOrchestrator();
}
return GestureHandlerOrchestrator._instance;
}
}
@@ -0,0 +1,227 @@
import findNodeHandle from '../../findNodeHandle';
import type IGestureHandler from '../handlers/IGestureHandler';
import {
GestureHandlerDelegate,
MeasureResult,
} from './GestureHandlerDelegate';
import PointerEventManager from './PointerEventManager';
import { State } from '../../State';
import { isPointerInBounds } from '../utils';
import EventManager from './EventManager';
import { Config } from '../interfaces';
import { MouseButton } from '../../handlers/gestureHandlerCommon';
import KeyboardEventManager from './KeyboardEventManager';
import WheelEventManager from './WheelEventManager';
interface DefaultViewStyles {
userSelect: string;
touchAction: string;
}
export class GestureHandlerWebDelegate
implements GestureHandlerDelegate<HTMLElement, IGestureHandler>
{
private isInitialized = false;
private _view!: HTMLElement;
private gestureHandler!: IGestureHandler;
private eventManagers: EventManager<unknown>[] = [];
private defaultViewStyles: DefaultViewStyles = {
userSelect: '',
touchAction: '',
};
init(viewRef: number, handler: IGestureHandler): void {
if (!viewRef) {
throw new Error(
`Cannot find HTML Element for handler ${handler.handlerTag}`
);
}
this.isInitialized = true;
this.gestureHandler = handler;
this.view = findNodeHandle(viewRef) as unknown as HTMLElement;
this.defaultViewStyles = {
userSelect: this.view.style.userSelect,
touchAction: this.view.style.touchAction,
};
const config = handler.config;
this.setUserSelect(config.enabled);
this.setTouchAction(config.enabled);
this.setContextMenu(config.enabled);
this.eventManagers.push(new PointerEventManager(this.view));
this.eventManagers.push(new KeyboardEventManager(this.view));
this.eventManagers.push(new WheelEventManager(this.view));
this.eventManagers.forEach((manager) =>
this.gestureHandler.attachEventManager(manager)
);
}
isPointerInBounds({ x, y }: { x: number; y: number }): boolean {
return isPointerInBounds(this.view, { x, y });
}
measureView(): MeasureResult {
const rect = this.view.getBoundingClientRect();
return {
pageX: rect.left,
pageY: rect.top,
width: rect.width,
height: rect.height,
};
}
reset(): void {
this.eventManagers.forEach((manager: EventManager<unknown>) =>
manager.resetManager()
);
}
tryResetCursor() {
const config = this.gestureHandler.config;
if (
config.activeCursor &&
config.activeCursor !== 'auto' &&
this.gestureHandler.state === State.ACTIVE
) {
this.view.style.cursor = 'auto';
}
}
private shouldDisableContextMenu(config: Config) {
return (
(config.enableContextMenu === undefined &&
this.gestureHandler.isButtonInConfig(MouseButton.RIGHT)) ||
config.enableContextMenu === false
);
}
private addContextMenuListeners(config: Config): void {
if (this.shouldDisableContextMenu(config)) {
this.view.addEventListener('contextmenu', this.disableContextMenu);
} else if (config.enableContextMenu) {
this.view.addEventListener('contextmenu', this.enableContextMenu);
}
}
private removeContextMenuListeners(config: Config): void {
if (this.shouldDisableContextMenu(config)) {
this.view.removeEventListener('contextmenu', this.disableContextMenu);
} else if (config.enableContextMenu) {
this.view.removeEventListener('contextmenu', this.enableContextMenu);
}
}
private disableContextMenu(this: void, e: MouseEvent): void {
e.preventDefault();
}
private enableContextMenu(this: void, e: MouseEvent): void {
e.stopPropagation();
}
private setUserSelect(isHandlerEnabled: boolean) {
const { userSelect } = this.gestureHandler.config;
this.view.style['userSelect'] = isHandlerEnabled
? (userSelect ?? 'none')
: this.defaultViewStyles.userSelect;
this.view.style['webkitUserSelect'] = isHandlerEnabled
? (userSelect ?? 'none')
: this.defaultViewStyles.userSelect;
}
private setTouchAction(isHandlerEnabled: boolean) {
const { touchAction } = this.gestureHandler.config;
this.view.style['touchAction'] = isHandlerEnabled
? (touchAction ?? 'none')
: this.defaultViewStyles.touchAction;
// @ts-ignore This one disables default events on Safari
this.view.style['WebkitTouchCallout'] = isHandlerEnabled
? (touchAction ?? 'none')
: this.defaultViewStyles.touchAction;
}
private setContextMenu(isHandlerEnabled: boolean) {
const config = this.gestureHandler.config;
if (isHandlerEnabled) {
this.addContextMenuListeners(config);
} else {
this.removeContextMenuListeners(config);
}
}
onEnabledChange(enabled: boolean): void {
if (!this.isInitialized) {
return;
}
this.setUserSelect(enabled);
this.setTouchAction(enabled);
this.setContextMenu(enabled);
if (enabled) {
this.eventManagers.forEach((manager) => {
manager.registerListeners();
});
} else {
this.eventManagers.forEach((manager) => {
manager.unregisterListeners();
});
}
}
onBegin(): void {
// no-op for now
}
onActivate(): void {
const config = this.gestureHandler.config;
if (
(!this.view.style.cursor || this.view.style.cursor === 'auto') &&
config.activeCursor
) {
this.view.style.cursor = config.activeCursor;
}
}
onEnd(): void {
this.tryResetCursor();
}
onCancel(): void {
this.tryResetCursor();
}
onFail(): void {
this.tryResetCursor();
}
public destroy(config: Config): void {
this.removeContextMenuListeners(config);
this.eventManagers.forEach((manager) => {
manager.unregisterListeners();
});
}
public get view() {
return this._view;
}
public set view(value: HTMLElement) {
this._view = value;
}
}
@@ -0,0 +1,135 @@
import type IGestureHandler from '../handlers/IGestureHandler';
import { Config, Handler } from '../interfaces';
export default class InteractionManager {
private static _instance: InteractionManager;
private readonly waitForRelations: Map<number, number[]> = new Map();
private readonly simultaneousRelations: Map<number, number[]> = new Map();
private readonly blocksHandlersRelations: Map<number, number[]> = new Map();
// Private becaues of singleton
// eslint-disable-next-line no-useless-constructor, @typescript-eslint/no-empty-function
private constructor() {}
public configureInteractions(handler: IGestureHandler, config: Config) {
this.dropRelationsForHandlerWithTag(handler.handlerTag);
if (config.waitFor) {
const waitFor: number[] = [];
config.waitFor.forEach((otherHandler: Handler): void => {
// New API reference
if (typeof otherHandler === 'number') {
waitFor.push(otherHandler);
} else {
// Old API reference
waitFor.push(otherHandler.handlerTag);
}
});
this.waitForRelations.set(handler.handlerTag, waitFor);
}
if (config.simultaneousHandlers) {
const simultaneousHandlers: number[] = [];
config.simultaneousHandlers.forEach((otherHandler: Handler): void => {
if (typeof otherHandler === 'number') {
simultaneousHandlers.push(otherHandler);
} else {
simultaneousHandlers.push(otherHandler.handlerTag);
}
});
this.simultaneousRelations.set(handler.handlerTag, simultaneousHandlers);
}
if (config.blocksHandlers) {
const blocksHandlers: number[] = [];
config.blocksHandlers.forEach((otherHandler: Handler): void => {
if (typeof otherHandler === 'number') {
blocksHandlers.push(otherHandler);
} else {
blocksHandlers.push(otherHandler.handlerTag);
}
});
this.blocksHandlersRelations.set(handler.handlerTag, blocksHandlers);
}
}
public shouldWaitForHandlerFailure(
handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
const waitFor: number[] | undefined = this.waitForRelations.get(
handler.handlerTag
);
return (
waitFor?.find((tag: number) => {
return tag === otherHandler.handlerTag;
}) !== undefined
);
}
public shouldRecognizeSimultaneously(
handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
const simultaneousHandlers: number[] | undefined =
this.simultaneousRelations.get(handler.handlerTag);
return (
simultaneousHandlers?.find((tag: number) => {
return tag === otherHandler.handlerTag;
}) !== undefined
);
}
public shouldRequireHandlerToWaitForFailure(
handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
const waitFor: number[] | undefined = this.blocksHandlersRelations.get(
handler.handlerTag
);
return (
waitFor?.find((tag: number) => {
return tag === otherHandler.handlerTag;
}) !== undefined
);
}
public shouldHandlerBeCancelledBy(
_handler: IGestureHandler,
otherHandler: IGestureHandler
): boolean {
// We check constructor name instead of using `instanceof` in order do avoid circular dependencies
const isNativeHandler =
otherHandler.constructor.name === 'NativeViewGestureHandler';
const isActive = otherHandler.active;
const isButton = otherHandler.isButton?.() === true;
return isNativeHandler && isActive && !isButton;
}
public dropRelationsForHandlerWithTag(handlerTag: number): void {
this.waitForRelations.delete(handlerTag);
this.simultaneousRelations.delete(handlerTag);
this.blocksHandlersRelations.delete(handlerTag);
}
public reset() {
this.waitForRelations.clear();
this.simultaneousRelations.clear();
this.blocksHandlersRelations.clear();
}
public static get instance(): InteractionManager {
if (!this._instance) {
this._instance = new InteractionManager();
}
return this._instance;
}
}
@@ -0,0 +1,133 @@
import { AdaptedEvent, EventTypes } from '../interfaces';
import EventManager from './EventManager';
import { PointerType } from '../../PointerType';
export default class KeyboardEventManager extends EventManager<HTMLElement> {
private static activationKeys = ['Enter', ' '];
private static cancelationKeys = ['Tab'];
private isPressed = false;
private static registeredStaticListeners = false;
private static instances: Set<KeyboardEventManager> = new Set();
private static keyUpStaticCallback = (event: KeyboardEvent): void => {
// We need a global listener, as in some cases, keyUp event gets stop-propagated.
// Then, if we used only component-level listeners the gesture would never end,
// causing other gestues to fail.
if (this.activationKeys.indexOf(event.key) === -1) {
return;
}
this.instances.forEach((item) => {
item.onKeyUp(event);
});
};
private keyDownCallback = (event: KeyboardEvent): void => {
if (
KeyboardEventManager.cancelationKeys.indexOf(event.key) !== -1 &&
this.isPressed
) {
this.dispatchEvent(event, EventTypes.CANCEL);
return;
}
if (KeyboardEventManager.activationKeys.indexOf(event.key) === -1) {
return;
}
this.dispatchEvent(event, EventTypes.DOWN);
};
private onKeyUp = (event: KeyboardEvent): void => {
if (
KeyboardEventManager.activationKeys.indexOf(event.key) === -1 ||
!this.isPressed
) {
return;
}
this.dispatchEvent(event, EventTypes.UP);
};
private dispatchEvent(event: KeyboardEvent, eventType: EventTypes) {
if (!(event.target instanceof HTMLElement)) {
return;
}
const adaptedEvent = this.mapEvent(event, eventType);
switch (eventType) {
case EventTypes.UP:
this.isPressed = false;
this.onPointerUp(adaptedEvent);
break;
case EventTypes.DOWN:
this.isPressed = true;
this.onPointerDown(adaptedEvent);
break;
case EventTypes.CANCEL:
this.isPressed = false;
this.onPointerCancel(adaptedEvent);
break;
}
}
public registerListeners(): void {
this.view.addEventListener('keydown', this.keyDownCallback);
KeyboardEventManager.instances.add(this);
if (!KeyboardEventManager.registeredStaticListeners) {
KeyboardEventManager.registeredStaticListeners = true;
document.addEventListener(
'keyup',
KeyboardEventManager.keyUpStaticCallback,
{ capture: true }
);
}
}
public unregisterListeners(): void {
this.view.removeEventListener('keydown', this.keyDownCallback);
KeyboardEventManager.instances.delete(this);
if (KeyboardEventManager.instances.size === 0) {
document.removeEventListener(
'keyup',
KeyboardEventManager.keyUpStaticCallback,
{ capture: true }
);
KeyboardEventManager.registeredStaticListeners = false;
}
}
protected mapEvent(
event: KeyboardEvent,
eventType: EventTypes
): AdaptedEvent {
const viewRect = (event.target as HTMLElement).getBoundingClientRect();
const viewportPosition = {
x: viewRect?.x + viewRect?.width / 2,
y: viewRect?.y + viewRect?.height / 2,
};
const relativePosition = {
x: viewRect?.width / 2,
y: viewRect?.height / 2,
};
return {
x: viewportPosition.x,
y: viewportPosition.y,
offsetX: relativePosition.x,
offsetY: relativePosition.y,
pointerId: 0,
eventType: eventType,
pointerType: PointerType.KEY,
time: event.timeStamp,
};
}
}
@@ -0,0 +1,182 @@
// Implementation taken from Flutter's LeastSquareSolver
// https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/gestures/lsq_solver.dart
class Vector {
private offset: number;
private length: number;
private elements: number[];
constructor(length: number) {
this.offset = 0;
this.length = length;
this.elements = new Array<number>(length);
}
public static fromVOL(
values: number[],
offset: number,
length: number
): Vector {
const result = new Vector(0);
result.offset = offset;
result.length = length;
result.elements = values;
return result;
}
public get(index: number): number {
return this.elements[this.offset + index];
}
public set(index: number, value: number): void {
this.elements[this.offset + index] = value;
}
public dot(other: Vector): number {
let result = 0;
for (let i = 0; i < this.length; i++) {
result += this.get(i) * other.get(i);
}
return result;
}
public norm() {
return Math.sqrt(this.dot(this));
}
}
class Matrix {
private columns: number;
private elements: number[];
constructor(rows: number, columns: number) {
this.columns = columns;
this.elements = new Array<number>(rows * columns);
}
public get(row: number, column: number): number {
return this.elements[row * this.columns + column];
}
public set(row: number, column: number, value: number): void {
this.elements[row * this.columns + column] = value;
}
public getRow(row: number): Vector {
return Vector.fromVOL(this.elements, row * this.columns, this.columns);
}
}
// An nth degree polynomial fit to a dataset.
class PolynomialFit {
// The polynomial coefficients of the fit.
//
// For each `i`, the element `coefficients[i]` is the coefficient of
// the `i`-th power of the variable.
public coefficients: number[];
// Creates a polynomial fit of the given degree.
//
// There are n + 1 coefficients in a fit of degree n.
constructor(degree: number) {
this.coefficients = new Array<number>(degree + 1);
}
}
const precisionErrorTolerance = 1e-10;
// Uses the least-squares algorithm to fit a polynomial to a set of data.
export default class LeastSquareSolver {
// The x-coordinates of each data point.
private x: number[];
// The y-coordinates of each data point.
private y: number[];
// The weight to use for each data point.
private w: number[];
// Creates a least-squares solver.
//
// The [x], [y], and [w] arguments must not be null.
constructor(x: number[], y: number[], w: number[]) {
this.x = x;
this.y = y;
this.w = w;
}
// Fits a polynomial of the given degree to the data points.
//
// When there is not enough data to fit a curve null is returned.
public solve(degree: number): PolynomialFit | null {
if (degree > this.x.length) {
// Not enough data to fit a curve.
return null;
}
const result = new PolynomialFit(degree);
// Shorthands for the purpose of notation equivalence to original C++ code.
const m = this.x.length;
const n = degree + 1;
// Expand the X vector to a matrix A, pre-multiplied by the weights.
const a = new Matrix(n, m);
for (let h = 0; h < m; h++) {
a.set(0, h, this.w[h]);
for (let i = 1; i < n; i++) {
a.set(i, h, a.get(i - 1, h) * this.x[h]);
}
}
// Apply the Gram-Schmidt process to A to obtain its QR decomposition.
// Orthonormal basis, column-major ordVectorer.
const q = new Matrix(n, m);
// Upper triangular matrix, row-major order.
const r = new Matrix(n, m);
for (let j = 0; j < n; j += 1) {
for (let h = 0; h < m; h += 1) {
q.set(j, h, a.get(j, h));
}
for (let i = 0; i < j; i += 1) {
const dot = q.getRow(j).dot(q.getRow(i));
for (let h = 0; h < m; h += 1) {
q.set(j, h, q.get(j, h) - dot * q.get(i, h));
}
}
const norm = q.getRow(j).norm();
if (norm < precisionErrorTolerance) {
// Vectors are linearly dependent or zero so no solution.
return null;
}
const inverseNorm = 1.0 / norm;
for (let h = 0; h < m; h += 1) {
q.set(j, h, q.get(j, h) * inverseNorm);
}
for (let i = 0; i < n; i += 1) {
r.set(j, i, i < j ? 0.0 : q.getRow(j).dot(a.getRow(i)));
}
}
// Solve R B = Qt W Y to find B. This is easy because R is upper triangular.
// We just work from bottom-right to top-left calculating B's coefficients.
const wy = new Vector(m);
for (let h = 0; h < m; h += 1) {
wy.set(h, this.y[h] * this.w[h]);
}
for (let i = n - 1; i >= 0; i -= 1) {
result.coefficients[i] = q.getRow(i).dot(wy);
for (let j = n - 1; j > i; j -= 1) {
result.coefficients[i] -= r.get(i, j) * result.coefficients[j];
}
result.coefficients[i] /= r.get(i, i);
}
return result;
}
}
@@ -0,0 +1,48 @@
import { ValueOf } from '../../typeUtils';
import { Gestures } from '../Gestures';
import type IGestureHandler from '../handlers/IGestureHandler';
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export default abstract class NodeManager {
private static gestures: Record<
number,
InstanceType<ValueOf<typeof Gestures>>
> = {};
public static getHandler(tag: number): IGestureHandler {
if (tag in this.gestures) {
return this.gestures[tag] as IGestureHandler;
}
throw new Error(`No handler for tag ${tag}`);
}
public static createGestureHandler(
handlerTag: number,
handler: InstanceType<ValueOf<typeof Gestures>>
): void {
if (handlerTag in this.gestures) {
throw new Error(
`Handler with tag ${handlerTag} already exists. Please ensure that no Gesture instance is used across multiple GestureDetectors.`
);
}
this.gestures[handlerTag] = handler;
this.gestures[handlerTag].handlerTag = handlerTag;
}
public static dropGestureHandler(handlerTag: number): void {
if (!(handlerTag in this.gestures)) {
return;
}
this.gestures[handlerTag].onDestroy();
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete this.gestures[handlerTag];
}
public static get nodes() {
return { ...this.gestures };
}
}
@@ -0,0 +1,226 @@
import EventManager from './EventManager';
import { MouseButton } from '../../handlers/gestureHandlerCommon';
import { AdaptedEvent, EventTypes, Point } from '../interfaces';
import {
PointerTypeMapping,
calculateViewScale,
tryExtractStylusData,
isPointerInBounds,
} from '../utils';
import { PointerType } from '../../PointerType';
const POINTER_CAPTURE_EXCLUDE_LIST = new Set<string>(['SELECT', 'INPUT']);
export default class PointerEventManager extends EventManager<HTMLElement> {
private trackedPointers = new Set<number>();
private readonly mouseButtonsMapper = new Map<number, MouseButton>();
private lastPosition: Point;
constructor(view: HTMLElement) {
super(view);
this.mouseButtonsMapper.set(0, MouseButton.LEFT);
this.mouseButtonsMapper.set(1, MouseButton.MIDDLE);
this.mouseButtonsMapper.set(2, MouseButton.RIGHT);
this.mouseButtonsMapper.set(3, MouseButton.BUTTON_4);
this.mouseButtonsMapper.set(4, MouseButton.BUTTON_5);
this.lastPosition = {
x: -Infinity,
y: -Infinity,
};
}
private pointerDownCallback = (event: PointerEvent) => {
if (!isPointerInBounds(this.view, { x: event.clientX, y: event.clientY })) {
return;
}
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.DOWN);
const target = event.target as HTMLElement;
if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) {
target.setPointerCapture(adaptedEvent.pointerId);
}
this.markAsInBounds(adaptedEvent.pointerId);
this.trackedPointers.add(adaptedEvent.pointerId);
if (++this.activePointersCounter > 1) {
adaptedEvent.eventType = EventTypes.ADDITIONAL_POINTER_DOWN;
this.onPointerAdd(adaptedEvent);
} else {
this.onPointerDown(adaptedEvent);
}
};
private pointerUpCallback = (event: PointerEvent) => {
// When we call reset on gesture handlers, it also resets their event managers
// In some handlers (like RotationGestureHandler) reset is called before all pointers leave view
// This means, that activePointersCounter will be set to 0, while there are still remaining pointers on view
// Removing them will end in activePointersCounter going below 0, therefore handlers won't behave properly
if (this.activePointersCounter === 0) {
return;
}
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.UP);
const target = event.target as HTMLElement;
if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) {
target.releasePointerCapture(adaptedEvent.pointerId);
}
this.markAsOutOfBounds(adaptedEvent.pointerId);
this.trackedPointers.delete(adaptedEvent.pointerId);
if (--this.activePointersCounter > 0) {
adaptedEvent.eventType = EventTypes.ADDITIONAL_POINTER_UP;
this.onPointerRemove(adaptedEvent);
} else {
this.onPointerUp(adaptedEvent);
}
};
private pointerMoveCallback = (event: PointerEvent) => {
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.MOVE);
const target = event.target as HTMLElement;
// You may be wondering why are we setting pointer capture here, when we
// already set it in `pointerdown` handler. Well, that's a great question,
// for which I don't have an answer. Specification (https://www.w3.org/TR/pointerevents2/#dom-element-setpointercapture)
// says that the requirement for `setPointerCapture` to work is that pointer
// must be in 'active buttons state`, otherwise it will fail silently, which
// is lovely. Obviously, when `pointerdown` is fired, one of the buttons
// (when using mouse) is pressed, but that doesn't mean that `setPointerCapture`
// will succeed, for some reason. Since it fails silently, we don't actually know
// if it worked or not (there's `gotpointercapture` event, but the complexity of
// incorporating it here seems stupid), so we just call it again here, every time
// pointer moves until it succeeds.
// God, I do love web development.
if (
!target?.hasPointerCapture(event.pointerId) &&
!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)
) {
target.setPointerCapture(event.pointerId);
}
const inBounds: boolean = isPointerInBounds(this.view, {
x: adaptedEvent.x,
y: adaptedEvent.y,
});
const pointerIndex: number = this.pointersInBounds.indexOf(
adaptedEvent.pointerId
);
if (inBounds) {
if (pointerIndex < 0) {
adaptedEvent.eventType = EventTypes.ENTER;
this.onPointerEnter(adaptedEvent);
this.markAsInBounds(adaptedEvent.pointerId);
} else {
this.onPointerMove(adaptedEvent);
}
} else {
if (pointerIndex >= 0) {
adaptedEvent.eventType = EventTypes.LEAVE;
this.onPointerLeave(adaptedEvent);
this.markAsOutOfBounds(adaptedEvent.pointerId);
} else {
this.onPointerOutOfBounds(adaptedEvent);
}
}
this.lastPosition.x = event.x;
this.lastPosition.y = event.y;
};
private pointerCancelCallback = (event: PointerEvent) => {
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.CANCEL);
this.onPointerCancel(adaptedEvent);
this.markAsOutOfBounds(adaptedEvent.pointerId);
this.activePointersCounter = 0;
this.trackedPointers.clear();
};
private pointerEnterCallback = (event: PointerEvent) => {
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.ENTER);
this.onPointerMoveOver(adaptedEvent);
};
private pointerLeaveCallback = (event: PointerEvent) => {
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.LEAVE);
this.onPointerMoveOut(adaptedEvent);
};
private lostPointerCaptureCallback = (event: PointerEvent) => {
const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.CANCEL);
if (this.trackedPointers.has(adaptedEvent.pointerId)) {
// In some cases the `pointerup` event is not fired, but `lostpointercapture` is.
// Here we simulate the `pointercancel` event to make sure the gesture handler stops tracking it.
this.onPointerCancel(adaptedEvent);
this.activePointersCounter = 0;
this.trackedPointers.clear();
}
};
public registerListeners(): void {
this.view.addEventListener('pointerdown', this.pointerDownCallback);
this.view.addEventListener('pointerup', this.pointerUpCallback);
this.view.addEventListener('pointermove', this.pointerMoveCallback);
this.view.addEventListener('pointercancel', this.pointerCancelCallback);
// onPointerEnter and onPointerLeave are triggered by a custom logic responsible for
// handling shouldCancelWhenOutside flag, and are unreliable unless the pointer is down.
// We therefore use pointerenter and pointerleave events to handle the hover gesture,
// mapping them to onPointerMoveOver and onPointerMoveOut respectively.
this.view.addEventListener('pointerenter', this.pointerEnterCallback);
this.view.addEventListener('pointerleave', this.pointerLeaveCallback);
this.view.addEventListener(
'lostpointercapture',
this.lostPointerCaptureCallback
);
}
public unregisterListeners(): void {
this.view.removeEventListener('pointerdown', this.pointerDownCallback);
this.view.removeEventListener('pointerup', this.pointerUpCallback);
this.view.removeEventListener('pointermove', this.pointerMoveCallback);
this.view.removeEventListener('pointercancel', this.pointerCancelCallback);
this.view.removeEventListener('pointerenter', this.pointerEnterCallback);
this.view.removeEventListener('pointerleave', this.pointerLeaveCallback);
this.view.removeEventListener(
'lostpointercapture',
this.lostPointerCaptureCallback
);
}
protected mapEvent(event: PointerEvent, eventType: EventTypes): AdaptedEvent {
const rect = this.view.getBoundingClientRect();
const { scaleX, scaleY } = calculateViewScale(this.view);
return {
x: event.clientX,
y: event.clientY,
offsetX: (event.clientX - rect.left) / scaleX,
offsetY: (event.clientY - rect.top) / scaleY,
pointerId: event.pointerId,
eventType: eventType,
pointerType:
PointerTypeMapping.get(event.pointerType) ?? PointerType.OTHER,
button: this.mouseButtonsMapper.get(event.button),
time: event.timeStamp,
stylusData: tryExtractStylusData(event),
};
}
public resetManager(): void {
super.resetManager();
this.trackedPointers.clear();
}
}
@@ -0,0 +1,228 @@
import { AdaptedEvent, Point } from '../interfaces';
import VelocityTracker from './VelocityTracker';
export interface TrackerElement {
abosoluteCoords: Point;
relativeCoords: Point;
timestamp: number;
velocityX: number;
velocityY: number;
}
const MAX_POINTERS = 20;
export default class PointerTracker {
private velocityTracker = new VelocityTracker();
private readonly _trackedPointers: Map<number, TrackerElement> = new Map<
number,
TrackerElement
>();
private touchEventsIds: Map<number, number> = new Map<number, number>();
private lastMovedPointerId: number;
private cachedAbsoluteAverages: Point = { x: 0, y: 0 };
private cachedRelativeAverages: Point = { x: 0, y: 0 };
public constructor() {
this.lastMovedPointerId = NaN;
for (let i = 0; i < MAX_POINTERS; ++i) {
this.touchEventsIds.set(i, NaN);
}
}
public addToTracker(event: AdaptedEvent): void {
if (this.trackedPointers.has(event.pointerId)) {
return;
}
this.lastMovedPointerId = event.pointerId;
const newElement: TrackerElement = {
abosoluteCoords: { x: event.x, y: event.y },
relativeCoords: { x: event.offsetX, y: event.offsetY },
timestamp: event.time,
velocityX: 0,
velocityY: 0,
};
this.trackedPointers.set(event.pointerId, newElement);
this.mapTouchEventId(event.pointerId);
this.cachedAbsoluteAverages = this.getAbsoluteCoordsAverage();
this.cachedRelativeAverages = this.getRelativeCoordsAverage();
}
public removeFromTracker(pointerId: number): void {
this.trackedPointers.delete(pointerId);
this.removeMappedTouchId(pointerId);
}
public track(event: AdaptedEvent): void {
const pointerData = this.trackedPointers.get(event.pointerId);
if (!pointerData) {
return;
}
this.lastMovedPointerId = event.pointerId;
this.velocityTracker.add(event);
const [velocityX, velocityY] = this.velocityTracker.velocity;
pointerData.velocityX = velocityX;
pointerData.velocityY = velocityY;
pointerData.abosoluteCoords = { x: event.x, y: event.y };
pointerData.relativeCoords = { x: event.offsetX, y: event.offsetY };
this.trackedPointers.set(event.pointerId, pointerData);
this.cachedAbsoluteAverages = this.getAbsoluteCoordsAverage();
this.cachedRelativeAverages = this.getRelativeCoordsAverage();
}
// Mapping TouchEvents ID
private mapTouchEventId(id: number): void {
for (const [mappedId, touchId] of this.touchEventsIds) {
if (isNaN(touchId)) {
this.touchEventsIds.set(mappedId, id);
break;
}
}
}
private removeMappedTouchId(id: number): void {
const mappedId: number = this.getMappedTouchEventId(id);
if (!isNaN(mappedId)) {
this.touchEventsIds.set(mappedId, NaN);
}
}
public getMappedTouchEventId(touchEventId: number): number {
for (const [key, value] of this.touchEventsIds.entries()) {
if (value === touchEventId) {
return key;
}
}
return NaN;
}
public getVelocity(pointerId: number) {
const pointerData = this.trackedPointers.get(pointerId);
return pointerData
? {
x: pointerData.velocityX,
y: pointerData.velocityY,
}
: null;
}
public getLastAbsoluteCoords(pointerId?: number) {
return this.trackedPointers.get(pointerId ?? this.lastMovedPointerId)
?.abosoluteCoords;
}
public getLastRelativeCoords(pointerId?: number) {
return this.trackedPointers.get(pointerId ?? this.lastMovedPointerId)
?.relativeCoords;
}
// Some handlers use these methods to send average values in native event.
// This may happen when pointers have already been removed from tracker (i.e. pointerup event).
// In situation when NaN would be sent as a response, we return cached value.
// That prevents handlers from crashing
public getAbsoluteCoordsAverage() {
const coordsSum = this.getAbsoluteCoordsSum();
const avgX = coordsSum.x / this.trackedPointers.size;
const avgY = coordsSum.y / this.trackedPointers.size;
const averages = {
x: isNaN(avgX) ? this.cachedAbsoluteAverages.x : avgX,
y: isNaN(avgY) ? this.cachedAbsoluteAverages.y : avgY,
};
return averages;
}
public getRelativeCoordsAverage() {
const coordsSum = this.getRelativeCoordsSum();
const avgX = coordsSum.x / this.trackedPointers.size;
const avgY = coordsSum.y / this.trackedPointers.size;
const averages = {
x: isNaN(avgX) ? this.cachedRelativeAverages.x : avgX,
y: isNaN(avgY) ? this.cachedRelativeAverages.y : avgY,
};
return averages;
}
public getAbsoluteCoordsSum(ignoredPointer?: number) {
const sum = { x: 0, y: 0 };
this.trackedPointers.forEach((value, key) => {
if (key !== ignoredPointer) {
sum.x += value.abosoluteCoords.x;
sum.y += value.abosoluteCoords.y;
}
});
return sum;
}
public getRelativeCoordsSum(ignoredPointer?: number) {
const sum = { x: 0, y: 0 };
this.trackedPointers.forEach((value, key) => {
if (key !== ignoredPointer) {
sum.x += value.relativeCoords.x;
sum.y += value.relativeCoords.y;
}
});
return sum;
}
public resetTracker(): void {
this.velocityTracker.reset();
this.trackedPointers.clear();
this.lastMovedPointerId = NaN;
for (let i = 0; i < MAX_POINTERS; ++i) {
this.touchEventsIds.set(i, NaN);
}
}
public static shareCommonPointers(
stPointers: number[],
ndPointers: number[]
): boolean {
return stPointers.some((pointerId) => ndPointers.includes(pointerId));
}
public get trackedPointersCount(): number {
return this.trackedPointers.size;
}
public get trackedPointersIDs() {
const keys: number[] = [];
this.trackedPointers.forEach((_value, key) => {
keys.push(key);
});
return keys;
}
public get trackedPointers() {
return this._trackedPointers;
}
}
@@ -0,0 +1,59 @@
import { DiagonalDirections, Directions } from '../../Directions';
import { MINIMAL_RECOGNIZABLE_MAGNITUDE } from '../constants';
import PointerTracker from './PointerTracker';
export default class Vector {
private readonly x;
private readonly y;
private readonly unitX;
private readonly unitY;
private readonly _magnitude;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
this._magnitude = Math.hypot(this.x, this.y);
const isMagnitudeSufficient =
this._magnitude > MINIMAL_RECOGNIZABLE_MAGNITUDE;
this.unitX = isMagnitudeSufficient ? this.x / this._magnitude : 0;
this.unitY = isMagnitudeSufficient ? this.y / this._magnitude : 0;
}
static fromDirection(direction: Directions | DiagonalDirections): Vector {
return DirectionToVectorMappings.get(direction) ?? new Vector(0, 0);
}
static fromVelocity(tracker: PointerTracker, pointerId: number) {
const velocity = tracker.getVelocity(pointerId);
return velocity ? new Vector(velocity.x, velocity.y) : null;
}
public get magnitude() {
return this._magnitude;
}
computeSimilarity(vector: Vector) {
return this.unitX * vector.unitX + this.unitY * vector.unitY;
}
isSimilar(vector: Vector, threshold: number) {
return this.computeSimilarity(vector) > threshold;
}
}
const DirectionToVectorMappings = new Map<
Directions | DiagonalDirections,
Vector
>([
[Directions.LEFT, new Vector(-1, 0)],
[Directions.RIGHT, new Vector(1, 0)],
[Directions.UP, new Vector(0, -1)],
[Directions.DOWN, new Vector(0, 1)],
[DiagonalDirections.UP_RIGHT, new Vector(1, -1)],
[DiagonalDirections.DOWN_RIGHT, new Vector(1, 1)],
[DiagonalDirections.UP_LEFT, new Vector(-1, -1)],
[DiagonalDirections.DOWN_LEFT, new Vector(-1, 1)],
]);
@@ -0,0 +1,98 @@
import { AdaptedEvent } from '../interfaces';
import CircularBuffer from './CircularBuffer';
import LeastSquareSolver from './LeastSquareSolver';
export default class VelocityTracker {
private assumePointerMoveStoppedMilliseconds = 40;
private historySize = 20;
private horizonMilliseconds = 300;
private minSampleSize = 3;
private samples: CircularBuffer<AdaptedEvent>;
constructor() {
this.samples = new CircularBuffer<AdaptedEvent>(this.historySize);
}
public add(event: AdaptedEvent): void {
this.samples.push(event);
}
// Returns an estimate of the velocity of the object being tracked by the
// tracker given the current information available to the tracker.
//
// Information is added using [addPosition].
//
// Returns null if there is no data on which to base an estimate.
private getVelocityEstimate(): [number, number] | null {
const x = [];
const y = [];
const w = [];
const time = [];
let sampleCount = 0;
let index = this.samples.size - 1;
const newestSample = this.samples.get(index);
if (!newestSample) {
return null;
}
let previousSample = newestSample;
// Starting with the most recent PointAtTime sample, iterate backwards while
// the samples represent continuous motion.
while (sampleCount < this.samples.size) {
const sample = this.samples.get(index);
const age = newestSample.time - sample.time;
const delta = Math.abs(sample.time - previousSample.time);
previousSample = sample;
if (
age > this.horizonMilliseconds ||
delta > this.assumePointerMoveStoppedMilliseconds
) {
break;
}
x.push(sample.x);
y.push(sample.y);
w.push(1);
time.push(-age);
sampleCount++;
index--;
}
if (sampleCount >= this.minSampleSize) {
const xSolver = new LeastSquareSolver(time, x, w);
const xFit = xSolver.solve(2);
if (xFit !== null) {
const ySolver = new LeastSquareSolver(time, y, w);
const yFit = ySolver.solve(2);
if (yFit !== null) {
const xVelocity = xFit.coefficients[1] * 1000;
const yVelocity = yFit.coefficients[1] * 1000;
return [xVelocity, yVelocity];
}
}
}
return null;
}
public get velocity(): [number, number] {
const estimate = this.getVelocityEstimate();
if (estimate !== null) {
return estimate;
}
return [0, 0];
}
public reset(): void {
this.samples.clear();
}
}
@@ -0,0 +1,48 @@
import EventManager from './EventManager';
import { AdaptedEvent, EventTypes } from '../interfaces';
import { PointerType } from '../../PointerType';
export default class WheelEventManager extends EventManager<HTMLElement> {
private wheelDelta = { x: 0, y: 0 };
private resetDelta = (_event: PointerEvent) => {
this.wheelDelta = { x: 0, y: 0 };
};
private wheelCallback = (event: WheelEvent) => {
this.wheelDelta.x += event.deltaX;
this.wheelDelta.y += event.deltaY;
const adaptedEvent = this.mapEvent(event);
this.onWheel(adaptedEvent);
};
public registerListeners(): void {
this.view.addEventListener('pointermove', this.resetDelta);
this.view.addEventListener('wheel', this.wheelCallback);
}
public unregisterListeners(): void {
this.view.removeEventListener('pointermove', this.resetDelta);
this.view.removeEventListener('wheel', this.wheelCallback);
}
protected mapEvent(event: WheelEvent): AdaptedEvent {
return {
x: event.clientX + this.wheelDelta.x,
y: event.clientY + this.wheelDelta.y,
offsetX: event.offsetX - event.deltaX,
offsetY: event.offsetY - event.deltaY,
pointerId: -1,
eventType: EventTypes.MOVE,
pointerType: PointerType.OTHER,
time: event.timeStamp,
// @ts-ignore It does exist, but it's deprecated
wheelDeltaY: event.wheelDeltaY,
};
}
public resetManager(): void {
super.resetManager();
}
}
@@ -0,0 +1,293 @@
import { PointerType } from '../PointerType';
import type {
GestureHandlerRef,
Point,
StylusData,
SVGRef,
} from './interfaces';
export function isPointerInBounds(view: HTMLElement, { x, y }: Point): boolean {
const rect: DOMRect = view.getBoundingClientRect();
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
export const PointerTypeMapping = new Map<string, PointerType>([
['mouse', PointerType.MOUSE],
['touch', PointerType.TOUCH],
['pen', PointerType.STYLUS],
['none', PointerType.OTHER],
]);
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);
const resultScales = {
scaleX: 1,
scaleY: 1,
};
// Get scales from scale property
if (styles.scale !== undefined && styles.scale !== 'none') {
const scales = styles.scale.split(' ');
if (scales[0]) {
resultScales.scaleX = parseFloat(scales[0]);
}
resultScales.scaleY = scales[1]
? parseFloat(scales[1])
: parseFloat(scales[0]);
}
// Get scales from transform property
const matrixElements = new RegExp(/matrix\((.+)\)/).exec(
styles.transform
)?.[1];
if (matrixElements) {
const matrixElementsArray = matrixElements.split(', ');
resultScales.scaleX *= parseFloat(matrixElementsArray[0]);
resultScales.scaleY *= parseFloat(matrixElementsArray[3]);
}
return resultScales;
}
export function tryExtractStylusData(
event: PointerEvent
): StylusData | undefined {
const pointerType = PointerTypeMapping.get(event.pointerType);
if (pointerType !== PointerType.STYLUS) {
return;
}
// @ts-ignore This property exists (https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent#instance_properties)
const eventAzimuthAngle: number | undefined = event.azimuthAngle;
// @ts-ignore This property exists (https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent#instance_properties)
const eventAltitudeAngle: number | undefined = event.altitudeAngle;
if (event.tiltX === 0 && event.tiltY === 0) {
// If we are in this branch, it means that either tilt properties are not supported and we have to calculate them from altitude and azimuth angles,
// or stylus is perpendicular to the screen and we can use altitude / azimuth instead of tilt
// If azimuth and altitude are undefined in this branch, it means that we are either perpendicular to the screen,
// or that none of the position sets is supported. In that case, we can treat stylus as perpendicular
if (eventAzimuthAngle === undefined || eventAltitudeAngle === undefined) {
return {
tiltX: 0,
tiltY: 0,
azimuthAngle: Math.PI / 2,
altitudeAngle: Math.PI / 2,
pressure: event.pressure,
};
}
const { tiltX, tiltY } = spherical2tilt(
eventAltitudeAngle,
eventAzimuthAngle
);
return {
tiltX,
tiltY,
azimuthAngle: eventAzimuthAngle,
altitudeAngle: eventAltitudeAngle,
pressure: event.pressure,
};
}
const { altitudeAngle, azimuthAngle } = tilt2spherical(
event.tiltX,
event.tiltY
);
return {
tiltX: event.tiltX,
tiltY: event.tiltY,
azimuthAngle,
altitudeAngle,
pressure: event.pressure,
};
}
// `altitudeAngle` and `azimuthAngle` are experimental properties, which are not supported on Firefox and Safari.
// Given that, we use `tilt` properties and algorithm that converts one value to another.
//
// Source: https://w3c.github.io/pointerevents/#converting-between-tiltx-tilty-and-altitudeangle-azimuthangle
function tilt2spherical(tiltX: number, tiltY: number) {
const tiltXrad = (tiltX * Math.PI) / 180;
const tiltYrad = (tiltY * Math.PI) / 180;
// calculate azimuth angle
let azimuthAngle = 0;
if (tiltX === 0) {
if (tiltY > 0) {
azimuthAngle = Math.PI / 2;
} else if (tiltY < 0) {
azimuthAngle = (3 * Math.PI) / 2;
}
} else if (tiltY === 0) {
if (tiltX < 0) {
azimuthAngle = Math.PI;
}
} else if (Math.abs(tiltX) === 90 || Math.abs(tiltY) === 90) {
// not enough information to calculate azimuth
azimuthAngle = 0;
} else {
// Non-boundary case: neither tiltX nor tiltY is equal to 0 or +-90
const tanX = Math.tan(tiltXrad);
const tanY = Math.tan(tiltYrad);
azimuthAngle = Math.atan2(tanY, tanX);
if (azimuthAngle < 0) {
azimuthAngle += 2 * Math.PI;
}
}
// calculate altitude angle
let altitudeAngle = 0;
if (Math.abs(tiltX) === 90 || Math.abs(tiltY) === 90) {
altitudeAngle = 0;
} else if (tiltX === 0) {
altitudeAngle = Math.PI / 2 - Math.abs(tiltYrad);
} else if (tiltY === 0) {
altitudeAngle = Math.PI / 2 - Math.abs(tiltXrad);
} else {
// Non-boundary case: neither tiltX nor tiltY is equal to 0 or +-90
altitudeAngle = Math.atan(
1.0 /
Math.sqrt(
Math.pow(Math.tan(tiltXrad), 2) + Math.pow(Math.tan(tiltYrad), 2)
)
);
}
return { altitudeAngle: altitudeAngle, azimuthAngle: azimuthAngle };
}
// If we are on a platform that doesn't support `tiltX` and `tiltY`, we have to calculate them from `altitude` and `azimuth` angles.
//
// Source: https://w3c.github.io/pointerevents/#converting-between-tiltx-tilty-and-altitudeangle-azimuthangle
function spherical2tilt(altitudeAngle: number, azimuthAngle: number) {
const radToDeg = 180 / Math.PI;
let tiltXrad = 0;
let tiltYrad = 0;
if (altitudeAngle === 0) {
// the pen is in the X-Y plane
if (azimuthAngle === 0 || azimuthAngle === 2 * Math.PI) {
// pen is on positive X axis
tiltXrad = Math.PI / 2;
}
if (azimuthAngle === Math.PI / 2) {
// pen is on positive Y axis
tiltYrad = Math.PI / 2;
}
if (azimuthAngle === Math.PI) {
// pen is on negative X axis
tiltXrad = -Math.PI / 2;
}
if (azimuthAngle === (3 * Math.PI) / 2) {
// pen is on negative Y axis
tiltYrad = -Math.PI / 2;
}
if (azimuthAngle > 0 && azimuthAngle < Math.PI / 2) {
tiltXrad = Math.PI / 2;
tiltYrad = Math.PI / 2;
}
if (azimuthAngle > Math.PI / 2 && azimuthAngle < Math.PI) {
tiltXrad = -Math.PI / 2;
tiltYrad = Math.PI / 2;
}
if (azimuthAngle > Math.PI && azimuthAngle < (3 * Math.PI) / 2) {
tiltXrad = -Math.PI / 2;
tiltYrad = -Math.PI / 2;
}
if (azimuthAngle > (3 * Math.PI) / 2 && azimuthAngle < 2 * Math.PI) {
tiltXrad = Math.PI / 2;
tiltYrad = -Math.PI / 2;
}
}
if (altitudeAngle !== 0) {
const tanAlt = Math.tan(altitudeAngle);
tiltXrad = Math.atan(Math.cos(azimuthAngle) / tanAlt);
tiltYrad = Math.atan(Math.sin(azimuthAngle) / tanAlt);
}
const tiltX = Math.round(tiltXrad * radToDeg);
const tiltY = Math.round(tiltYrad * radToDeg);
return { tiltX, tiltY };
}
export const RNSVGElements = new Set([
'Circle',
'ClipPath',
'Ellipse',
'ForeignObject',
'G',
'Image',
'Line',
'Marker',
'Mask',
'Path',
'Pattern',
'Polygon',
'Polyline',
'Rect',
'Svg',
'Symbol',
'TSpan',
'Text',
'TextPath',
'Use',
]);
// This function helps us determine whether given node is SVGElement or not. In our implementation of
// findNodeHandle, we can encounter such element in 2 forms - SVG tag or ref to SVG Element. Since Gesture Handler
// does not depend on SVG, we use our simplified SVGRef type that has `elementRef` field. This is something that is present
// in actual SVG ref object.
//
// In order to make sure that node passed into this function is in fact SVG element, first we check if its constructor name
// corresponds to one of the possible SVG elements. Then we also check if `elementRef` field exists.
// By doing both steps we decrease probability of detecting situations where, for example, user makes custom `Circle` and
// we treat it as SVG.
export function isRNSVGElement(viewRef: SVGRef | GestureHandlerRef) {
const componentClassName = Object.getPrototypeOf(viewRef).constructor.name;
return (
RNSVGElements.has(componentClassName) &&
Object.hasOwn(viewRef, 'elementRef')
);
}
// This function checks if given node is SVGElement. Unlike the function above, this one
// operates on React Nodes, not DOM nodes.
//
// Second condition was introduced to handle case where SVG element was wrapped with
// `createAnimatedComponent` from Reanimated.
export function isRNSVGNode(node: any) {
// If `ref` has `rngh` field, it means that component comes from Gesture Handler. This is a special case for
// `Text` component, which is present in `RNSVGElements` set, yet we don't want to treat it as SVG.
if (node.ref?.rngh) {
return false;
}
return (
Object.getPrototypeOf(node?.type)?.name === 'WebShape' ||
RNSVGElements.has(node?.type?.displayName)
);
}