/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as DomUtils from '../Dom'; import { mainWindow } from './window'; import * as arrays from './arrays'; import { memoize } from './decorators'; import { Disposable, IDisposable, toDisposable } from 'common/Lifecycle'; import { LinkedList } from './linkedList'; export namespace EventType { export const TAP = '-xterm-gesturetap'; export const CHANGE = '-xterm-gesturechange'; export const START = '-xterm-gesturestart'; export const END = '-xterm-gesturesend'; export const CONTEXT_MENU = '-xterm-gesturecontextmenu'; } interface ITouchData { id: number; initialTarget: EventTarget; initialTimeStamp: number; initialPageX: number; initialPageY: number; rollingTimestamps: number[]; rollingPageX: number[]; rollingPageY: number[]; } export interface IGestureEvent extends MouseEvent { initialTarget: EventTarget | undefined; translationX: number; translationY: number; pageX: number; pageY: number; tapCount: number; } interface ITouch { identifier: number; screenX: number; screenY: number; clientX: number; clientY: number; pageX: number; pageY: number; radiusX: number; radiusY: number; rotationAngle: number; force: number; target: Element; } interface ITouchList { [i: number]: ITouch; length: number; item(index: number): ITouch; identifiedTouch(id: number): ITouch; } interface ITouchEvent extends Event { touches: ITouchList; targetTouches: ITouchList; changedTouches: ITouchList; } export class Gesture extends Disposable { private static readonly _scrollFriction = -0.005; private static _instance: Gesture; private static readonly _holdDelay = 700; private _dispatched = false; private readonly _targets = new LinkedList(); private readonly _ignoreTargets = new LinkedList(); private _handle: IDisposable | null; private readonly _activeTouches: { [id: number]: ITouchData }; private _lastSetTapCountTime: number; private static readonly _clearTapCountTime = 400; // ms private constructor() { super(); this._activeTouches = {}; this._handle = null; this._lastSetTapCountTime = 0; const targetWindow = mainWindow; this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchstart', (e: ITouchEvent) => this._handleTouchStart(e), { passive: false })); this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchend', (e: ITouchEvent) => this._handleTouchEnd(targetWindow, e))); this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchmove', (e: ITouchEvent) => this._handleTouchMove(e), { passive: false })); } public static addTarget(element: HTMLElement): IDisposable { if (!Gesture.isTouchDevice()) { return Disposable.None; } if (!Gesture._instance) { Gesture._instance = new Gesture(); } const remove = Gesture._instance._targets.push(element); return toDisposable(remove); } public static ignoreTarget(element: HTMLElement): IDisposable { if (!Gesture.isTouchDevice()) { return Disposable.None; } if (!Gesture._instance) { Gesture._instance = new Gesture(); } const remove = Gesture._instance._ignoreTargets.push(element); return toDisposable(remove); } @memoize public static isTouchDevice(): boolean { return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0; } public override dispose(): void { if (this._handle) { this._handle.dispose(); this._handle = null; } super.dispose(); } private _handleTouchStart(e: ITouchEvent): void { const timestamp = Date.now(); if (this._handle) { this._handle.dispose(); this._handle = null; } for (let i = 0, len = e.targetTouches.length; i < len; i++) { const touch = e.targetTouches.item(i); this._activeTouches[touch.identifier] = { id: touch.identifier, initialTarget: touch.target, initialTimeStamp: timestamp, initialPageX: touch.pageX, initialPageY: touch.pageY, rollingTimestamps: [timestamp], rollingPageX: [touch.pageX], rollingPageY: [touch.pageY] }; const evt = this._newGestureEvent(EventType.START, touch.target); evt.pageX = touch.pageX; evt.pageY = touch.pageY; this._dispatchEvent(evt); } if (this._dispatched) { e.preventDefault(); e.stopPropagation(); this._dispatched = false; } } private _handleTouchEnd(targetWindow: Window, e: ITouchEvent): void { const timestamp = Date.now(); const activeTouchCount = Object.keys(this._activeTouches).length; for (let i = 0, len = e.changedTouches.length; i < len; i++) { const touch = e.changedTouches.item(i); if (!this._activeTouches.hasOwnProperty(String(touch.identifier))) { console.warn('move of an UNKNOWN touch', touch); continue; } const data = this._activeTouches[touch.identifier]; const holdTime = Date.now() - data.initialTimeStamp; if (holdTime < Gesture._holdDelay && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)!) < 30 && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)!) < 30) { const evt = this._newGestureEvent(EventType.TAP, data.initialTarget); evt.pageX = arrays.tail(data.rollingPageX)!; evt.pageY = arrays.tail(data.rollingPageY)!; this._dispatchEvent(evt); } else if (holdTime >= Gesture._holdDelay && Math.abs(data.initialPageX - arrays.tail(data.rollingPageX)!) < 30 && Math.abs(data.initialPageY - arrays.tail(data.rollingPageY)!) < 30) { const evt = this._newGestureEvent(EventType.CONTEXT_MENU, data.initialTarget); evt.pageX = arrays.tail(data.rollingPageX)!; evt.pageY = arrays.tail(data.rollingPageY)!; this._dispatchEvent(evt); } else if (activeTouchCount === 1) { const finalX = arrays.tail(data.rollingPageX)!; const finalY = arrays.tail(data.rollingPageY)!; const deltaT = arrays.tail(data.rollingTimestamps)! - data.rollingTimestamps[0]; const deltaX = finalX - data.rollingPageX[0]; const deltaY = finalY - data.rollingPageY[0]; const dispatchTo = [...this._targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget)); this._inertia(targetWindow, dispatchTo, timestamp, Math.abs(deltaX) / deltaT, deltaX > 0 ? 1 : -1, finalX, Math.abs(deltaY) / deltaT, deltaY > 0 ? 1 : -1, finalY ); } this._dispatchEvent(this._newGestureEvent(EventType.END, data.initialTarget)); delete this._activeTouches[touch.identifier]; } if (this._dispatched) { e.preventDefault(); e.stopPropagation(); this._dispatched = false; } } private _newGestureEvent(type: string, initialTarget?: EventTarget): IGestureEvent { const event = document.createEvent('CustomEvent') as unknown as IGestureEvent; event.initEvent(type, false, true); event.initialTarget = initialTarget; event.tapCount = 0; return event; } private _dispatchEvent(event: IGestureEvent): void { if (event.type === EventType.TAP) { const currentTime = (new Date()).getTime(); let setTapCount = 0; if (currentTime - this._lastSetTapCountTime > Gesture._clearTapCountTime) { setTapCount = 1; } else { setTapCount = 2; } this._lastSetTapCountTime = currentTime; event.tapCount = setTapCount; } else if (event.type === EventType.CHANGE || event.type === EventType.CONTEXT_MENU) { this._lastSetTapCountTime = 0; } if (event.initialTarget instanceof Node) { for (const ignoreTarget of this._ignoreTargets) { if (ignoreTarget.contains(event.initialTarget)) { return; } } const targets: [number, HTMLElement][] = []; for (const target of this._targets) { if (target.contains(event.initialTarget)) { let depth = 0; let now: Node | null = event.initialTarget; while (now && now !== target) { depth++; now = now.parentElement; } targets.push([depth, target]); } } targets.sort((a, b) => a[0] - b[0]); for (const [, target] of targets) { target.dispatchEvent(event); this._dispatched = true; } } } private _inertia(targetWindow: Window, dispatchTo: ReadonlyArray, t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void { this._handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => { const now = Date.now(); const deltaT = now - t1; let deltaPosX = 0; let deltaPosY = 0; let stopped = true; vX += Gesture._scrollFriction * deltaT; vY += Gesture._scrollFriction * deltaT; if (vX > 0) { stopped = false; deltaPosX = dirX * vX * deltaT; } if (vY > 0) { stopped = false; deltaPosY = dirY * vY * deltaT; } const evt = this._newGestureEvent(EventType.CHANGE); evt.translationX = deltaPosX; evt.translationY = deltaPosY; dispatchTo.forEach(d => d.dispatchEvent(evt)); if (!stopped) { this._inertia(targetWindow, dispatchTo, now, vX, dirX, x + deltaPosX, vY, dirY, y + deltaPosY); } }); } private _handleTouchMove(e: ITouchEvent): void { const timestamp = Date.now(); for (let i = 0, len = e.changedTouches.length; i < len; i++) { const touch = e.changedTouches.item(i); if (!this._activeTouches.hasOwnProperty(String(touch.identifier))) { console.warn('end of an UNKNOWN touch', touch); continue; } const data = this._activeTouches[touch.identifier]; const evt = this._newGestureEvent(EventType.CHANGE, data.initialTarget); evt.translationX = touch.pageX - arrays.tail(data.rollingPageX)!; evt.translationY = touch.pageY - arrays.tail(data.rollingPageY)!; evt.pageX = touch.pageX; evt.pageY = touch.pageY; this._dispatchEvent(evt); if (data.rollingPageX.length > 3) { data.rollingPageX.shift(); data.rollingPageY.shift(); data.rollingTimestamps.shift(); } data.rollingPageX.push(touch.pageX); data.rollingPageY.push(touch.pageY); data.rollingTimestamps.push(timestamp); } if (this._dispatched) { e.preventDefault(); e.stopPropagation(); this._dispatched = false; } } }