Files
xterm.js/src/browser/scrollable/touch.ts
T

364 lines
11 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* 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<HTMLElement>();
private readonly _ignoreTargets = new LinkedList<HTMLElement>();
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<EventTarget>, 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;
}
}
}