/** * GestureView.tsx * * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT license. * * Web-specific implementation of the cross-platform GestureView component. * It provides support for the scroll wheel, clicks and double clicks. */ import * as PropTypes from 'prop-types'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; import GestureViewCommon, { GestureStatePoint, GestureStatePointVelocity, GestureType, TouchEventBasic, TouchListBasic } from '../common/GestureView'; import { Types } from '../common/Interfaces'; import AccessibilityUtil from './AccessibilityUtil'; import { clone } from './utils/lodashMini'; import MouseResponder, { MouseResponderSubscription } from './utils/MouseResponder'; import Styles from './Styles'; // Cast to any to allow merging of web and RX styles const _styles = { defaultView: { position: 'relative', display: 'flex', flexDirection: 'column', flexGrow: 0, flexShrink: 0, overflow: 'hidden', alignItems: 'stretch', justifyContent: 'center' } as any }; // Unique to web const _preferredPanRatio = 3; export interface GestureViewContext { isInRxMainView?: boolean; } let _idCounter = 1; interface Point2D { x: number; y: number; } export abstract class GestureView extends GestureViewCommon { private _id = _idCounter++; private _isMounted = false; private _container: HTMLElement | null | undefined; private _initialTouch: Point2D | undefined; private _ongoingGesture: GestureStatePointVelocity | undefined; private _responder: MouseResponderSubscription | undefined; private _pendingMouseGestureType = GestureType.None; private _gestureTypeLocked = false; static contextTypes: React.ValidationMap = { isInRxMainView: PropTypes.bool }; // Get preferred pan ratio for platform. protected _getPreferredPanRatio(): number { return _preferredPanRatio; } // Returns the timestamp for the touch event in milliseconds. protected _getEventTimestamp(e: Types.TouchEvent | Types.MouseEvent): number { return e.timeStamp; } componentDidMount() { this._isMounted = true; } componentWillUnmount() { super.componentWillUnmount(); this._isMounted = false; } render() { const ariaRole = AccessibilityUtil.accessibilityTraitToString(this.props.accessibilityTraits); const isAriaHidden = AccessibilityUtil.isHidden(this.props.importantForAccessibility); return (
{ this.props.children }
); } blur() { const el = this._getContainer(); if (el) { el.blur(); } } focus() { const el = this._getContainer(); if (el) { el.focus(); } } protected _getContainer(): HTMLElement | null { if (!this._isMounted) { return null; } try { return ReactDOM.findDOMNode(this) as HTMLElement | null; } catch { // Handle exception due to potential unmount race condition. return null; } } private _createMouseResponder(container: HTMLElement) { this._disposeMouseResponder(); this._responder = MouseResponder.create({ id: this._id, target: container, disableWhenModal: !!this.context.isInRxMainView, shouldBecomeFirstResponder: (event: MouseEvent) => { if (!this.props.onPan && !this.props.onPanHorizontal && !this.props.onPanVertical) { return false; } const boundingRect = this._getGestureViewClientRect(); if (!boundingRect) { return false; } const { top, left, bottom, right } = boundingRect; const { clientX, clientY } = event; if (clientX >= left && clientX <= right && clientY >= top && clientY <= bottom) { return true; } return false; }, onMove: (event: MouseEvent, gestureState: Types.PanGestureState) => { this._pendingMouseGestureType = this._detectGestureType(gestureState); if (this._pendingMouseGestureType !== GestureType.None) { this._cancelLongPressTimer(); } this._sendMousePanEvent(gestureState); }, onTerminate: (event: MouseEvent, gestureState: Types.PanGestureState) => { this._cancelLongPressTimer(); this._pendingMouseGestureType = this._detectGestureType(gestureState); this._sendMousePanEvent(gestureState); this._pendingMouseGestureType = GestureType.None; this._gestureTypeLocked = false; } }); } private _disposeMouseResponder() { if (this._responder) { this._responder.dispose(); delete this._responder; } } private _setContainerRef = (container: HTMLElement | null) => { // safe since div refs resolve into HTMLElement and not react element. this._container = container; if (container) { this._createMouseResponder(container); } else { this._disposeMouseResponder(); } } private _getStyles(): any { const combinedStyles = Styles.combine([_styles.defaultView, this.props.style]); let cursorName: string | undefined; switch (this.props.mouseOverCursor) { case Types.GestureMouseCursor.Grab: cursorName = 'grab'; break; case Types.GestureMouseCursor.Move: cursorName = 'move'; break; case Types.GestureMouseCursor.Pointer: cursorName = 'pointer'; break; case Types.GestureMouseCursor.NSResize: cursorName = 'ns-resize'; break; case Types.GestureMouseCursor.EWResize: cursorName = 'ew-resize'; break; case Types.GestureMouseCursor.NESWResize: cursorName = 'nesw-resize'; break; case Types.GestureMouseCursor.NWSEResize: cursorName = 'nwse-resize'; break; case Types.GestureMouseCursor.NotAllowed: cursorName = 'not-allowed'; break; case Types.GestureMouseCursor.ZoomIn: cursorName = 'zoom-in'; break; case Types.GestureMouseCursor.ZoomOut: cursorName = 'zoom-out'; break; } if (cursorName) { combinedStyles.cursor = cursorName; } return combinedStyles; } private _onMouseDown = (e: React.MouseEvent) => { if (this.props.onPan || this.props.onPanHorizontal || this.props.onPanVertical) { // Disable mousedown default action that initiates a drag/drop operation and breaks panning with a not-allowed cursor. // https://w3c.github.io/uievents/#mousedown e.preventDefault(); } if (this.props.onLongPress) { const gsState = this._mouseEventToTapGestureState(e); this._startLongPressTimer(gsState, true); } } private _onClick = (e: React.MouseEvent) => { this._cancelLongPressTimer(); const gsState = this._mouseEventToTapGestureState(e); if (!this.props.onDoubleTap) { // If there is no double-tap handler, we can invoke the tap handler immediately. this._sendTapEvent(gsState); } else if (this._isDoubleTap(gsState)) { // This is a double-tap, so swallow the previous single tap. this._cancelDoubleTapTimer(); this._sendDoubleTapEvent(gsState); } else { // This wasn't a double-tap. Report any previous single tap and start the double-tap // timer so we can determine whether the current tap is a single or double. this._reportDelayedTap(); this._startDoubleTapTimer(gsState); } } private _sendContextMenuEvent = (e: React.MouseEvent) => { if (this.props.onContextMenu) { e.preventDefault(); e.stopPropagation(); const tapEvent = this._mouseEventToTapGestureState(e); this.props.onContextMenu(tapEvent); } } // The RN and React touch event types are basically identical except that React uses "clientX/Y" // and RN uses "locationX/Y", so we need to map one to the other. Unfortunately, due to inertia, // web loses. So, we need these 3 ugly functions... private static _reactTouchEventToBasic(e: React.TouchEvent): TouchEventBasic { const ne = clone(e) as any as TouchEventBasic; ne.changedTouches = this._mapReactTouchListToBasic(e.changedTouches); ne.targetTouches = this._mapReactTouchListToBasic(e.targetTouches); ne.touches = this._mapReactTouchListToBasic(e.touches); const ft = ne.touches[0]; if (ft) { // RN also apparently shims the first touch's location info onto the root touch event ne.pageX = ft.pageX; ne.pageY = ft.pageY; ne.locationX = ft.locationX; ne.locationY = ft.locationY; } return ne; } private static _mapReactTouchListToBasic(l: React.TouchList): TouchListBasic { const nl: Types.Touch[] = new Array(l.length); for (let i = 0; i < l.length; i++) { nl[i] = this._mapReactTouchToRx(l[i]); } return nl; } private static _mapReactTouchToRx(l: React.Touch): Types.Touch { return { identifier: l.identifier, locationX: l.clientX, locationY: l.clientY, screenX: l.screenX, screenY: l.screenY, clientX: l.clientX, clientY: l.clientY, pageX: l.pageX, pageY: l.pageY }; } private _onTouchStart = (e: React.TouchEvent) => { if (!this._initialTouch) { const ft = e.touches[0]; this._initialTouch = { x: ft.clientX, y: ft.clientY }; this._ongoingGesture = { dx: 0, dy: 0, vx: 0, vy: 0 }; this._onTouchSeriesStart(GestureView._reactTouchEventToBasic(e)); } } private _onTouchMove = (e: React.TouchEvent) => { if (!this._initialTouch || !this._ongoingGesture) { return; } const ft = e.touches[0]; this._ongoingGesture = { dx: ft.clientX - this._initialTouch.x, dy: ft.clientY - this._initialTouch.y, // TODO: calculate velocity? vx: 0, vy: 0 }; this._onTouchChange(GestureView._reactTouchEventToBasic(e), this._ongoingGesture); } private _onTouchEnd = (e: React.TouchEvent) => { if (!this._initialTouch || !this._ongoingGesture) { return; } if (e.touches.length === 0) { this._onTouchSeriesFinished(GestureView._reactTouchEventToBasic(e), this._ongoingGesture); this._initialTouch = undefined; this._ongoingGesture = undefined; } } private _detectGestureType = (gestureState: Types.PanGestureState) => { // we need to lock gesture type until it's completed if (this._gestureTypeLocked) { return this._pendingMouseGestureType; } this._gestureTypeLocked = true; const gsBasic: GestureStatePoint = { dx: gestureState.clientX - gestureState.initialClientX, dy: gestureState.clientY - gestureState.initialClientY }; if (this._shouldRespondToPan(gsBasic)) { return GestureType.Pan; } else if (this._shouldRespondToPanVertical(gsBasic)) { return GestureType.PanVertical; } else if (this._shouldRespondToPanHorizontal(gsBasic)) { return GestureType.PanHorizontal; } this._gestureTypeLocked = false; return GestureType.None; } private _onWheel = (e: React.WheelEvent) => { if (this.props.onScrollWheel) { const clientRect = this._getGestureViewClientRect(); if (clientRect) { const scrollWheelEvent: Types.ScrollWheelGestureState = { clientX: e.clientX - clientRect.left, clientY: e.clientY - clientRect.top, pageX: e.pageX, pageY: e.pageY, scrollAmount: e.deltaY, timeStamp: e.timeStamp, isTouch: false }; this.props.onScrollWheel(scrollWheelEvent); } } } private _sendMousePanEvent = (gestureState: Types.PanGestureState) => { switch (this._pendingMouseGestureType) { case GestureType.Pan: if (this.props.onPan) { this.props.onPan(gestureState); } break; case GestureType.PanVertical: if (this.props.onPanVertical) { this.props.onPanVertical(gestureState); } break; case GestureType.PanHorizontal: if (this.props.onPanHorizontal) { this.props.onPanHorizontal(gestureState); } break; default: // do nothing; } // we need to clean taps in case there was a pan event in the meantime if (this._pendingMouseGestureType !== GestureType.None) { this._clearLastTap(); this._cancelDoubleTapTimer(); this._skipNextTap(); } } protected _getClientXYOffset(): { x: number; y: number } { const rect = this._getGestureViewClientRect(); return rect ? { x: rect.left, y: rect.top } : { x: 0, y: 0 }; } private _getGestureViewClientRect() { return this._container ? this._container.getBoundingClientRect() : null; } } export default GestureView;