Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/touch.ts
3292 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as DomUtils from './dom.js';
7
import { mainWindow } from './window.js';
8
import { memoize } from '../common/decorators.js';
9
import { Event as EventUtils } from '../common/event.js';
10
import { Disposable, IDisposable, markAsSingleton, toDisposable } from '../common/lifecycle.js';
11
import { LinkedList } from '../common/linkedList.js';
12
13
export namespace EventType {
14
export const Tap = '-monaco-gesturetap';
15
export const Change = '-monaco-gesturechange';
16
export const Start = '-monaco-gesturestart';
17
export const End = '-monaco-gesturesend';
18
export const Contextmenu = '-monaco-gesturecontextmenu';
19
}
20
21
interface TouchData {
22
id: number;
23
initialTarget: EventTarget;
24
initialTimeStamp: number;
25
initialPageX: number;
26
initialPageY: number;
27
rollingTimestamps: number[];
28
rollingPageX: number[];
29
rollingPageY: number[];
30
}
31
32
export interface GestureEvent extends MouseEvent {
33
initialTarget: EventTarget | undefined;
34
translationX: number;
35
translationY: number;
36
pageX: number;
37
pageY: number;
38
tapCount: number;
39
}
40
41
interface Touch {
42
identifier: number;
43
screenX: number;
44
screenY: number;
45
clientX: number;
46
clientY: number;
47
pageX: number;
48
pageY: number;
49
radiusX: number;
50
radiusY: number;
51
rotationAngle: number;
52
force: number;
53
target: Element;
54
}
55
56
interface TouchList {
57
[i: number]: Touch;
58
length: number;
59
item(index: number): Touch;
60
identifiedTouch(id: number): Touch;
61
}
62
63
interface TouchEvent extends Event {
64
touches: TouchList;
65
targetTouches: TouchList;
66
changedTouches: TouchList;
67
}
68
69
export class Gesture extends Disposable {
70
71
private static readonly SCROLL_FRICTION = -0.005;
72
private static INSTANCE: Gesture;
73
private static readonly HOLD_DELAY = 700;
74
75
private dispatched = false;
76
private readonly targets = new LinkedList<HTMLElement>();
77
private readonly ignoreTargets = new LinkedList<HTMLElement>();
78
private handle: IDisposable | null;
79
80
private readonly activeTouches: { [id: number]: TouchData };
81
82
private _lastSetTapCountTime: number;
83
84
private static readonly CLEAR_TAP_COUNT_TIME = 400; // ms
85
86
87
private constructor() {
88
super();
89
90
this.activeTouches = {};
91
this.handle = null;
92
this._lastSetTapCountTime = 0;
93
94
this._register(EventUtils.runAndSubscribe(DomUtils.onDidRegisterWindow, ({ window, disposables }) => {
95
disposables.add(DomUtils.addDisposableListener(window.document, 'touchstart', (e: TouchEvent) => this.onTouchStart(e), { passive: false }));
96
disposables.add(DomUtils.addDisposableListener(window.document, 'touchend', (e: TouchEvent) => this.onTouchEnd(window, e)));
97
disposables.add(DomUtils.addDisposableListener(window.document, 'touchmove', (e: TouchEvent) => this.onTouchMove(e), { passive: false }));
98
}, { window: mainWindow, disposables: this._store }));
99
}
100
101
public static addTarget(element: HTMLElement): IDisposable {
102
if (!Gesture.isTouchDevice()) {
103
return Disposable.None;
104
}
105
if (!Gesture.INSTANCE) {
106
Gesture.INSTANCE = markAsSingleton(new Gesture());
107
}
108
109
const remove = Gesture.INSTANCE.targets.push(element);
110
return toDisposable(remove);
111
}
112
113
public static ignoreTarget(element: HTMLElement): IDisposable {
114
if (!Gesture.isTouchDevice()) {
115
return Disposable.None;
116
}
117
if (!Gesture.INSTANCE) {
118
Gesture.INSTANCE = markAsSingleton(new Gesture());
119
}
120
121
const remove = Gesture.INSTANCE.ignoreTargets.push(element);
122
return toDisposable(remove);
123
}
124
125
@memoize
126
static isTouchDevice(): boolean {
127
// `'ontouchstart' in window` always evaluates to true with typescript's modern typings. This causes `window` to be
128
// `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast
129
return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0;
130
}
131
132
public override dispose(): void {
133
if (this.handle) {
134
this.handle.dispose();
135
this.handle = null;
136
}
137
138
super.dispose();
139
}
140
141
private onTouchStart(e: TouchEvent): void {
142
const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based.
143
144
if (this.handle) {
145
this.handle.dispose();
146
this.handle = null;
147
}
148
149
for (let i = 0, len = e.targetTouches.length; i < len; i++) {
150
const touch = e.targetTouches.item(i);
151
152
this.activeTouches[touch.identifier] = {
153
id: touch.identifier,
154
initialTarget: touch.target,
155
initialTimeStamp: timestamp,
156
initialPageX: touch.pageX,
157
initialPageY: touch.pageY,
158
rollingTimestamps: [timestamp],
159
rollingPageX: [touch.pageX],
160
rollingPageY: [touch.pageY]
161
};
162
163
const evt = this.newGestureEvent(EventType.Start, touch.target);
164
evt.pageX = touch.pageX;
165
evt.pageY = touch.pageY;
166
this.dispatchEvent(evt);
167
}
168
169
if (this.dispatched) {
170
e.preventDefault();
171
e.stopPropagation();
172
this.dispatched = false;
173
}
174
}
175
176
private onTouchEnd(targetWindow: Window, e: TouchEvent): void {
177
const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based.
178
179
const activeTouchCount = Object.keys(this.activeTouches).length;
180
181
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
182
183
const touch = e.changedTouches.item(i);
184
185
if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) {
186
console.warn('move of an UNKNOWN touch', touch);
187
continue;
188
}
189
190
const data = this.activeTouches[touch.identifier],
191
holdTime = Date.now() - data.initialTimeStamp;
192
193
if (holdTime < Gesture.HOLD_DELAY
194
&& Math.abs(data.initialPageX - data.rollingPageX.at(-1)!) < 30
195
&& Math.abs(data.initialPageY - data.rollingPageY.at(-1)!) < 30) {
196
197
const evt = this.newGestureEvent(EventType.Tap, data.initialTarget);
198
evt.pageX = data.rollingPageX.at(-1)!;
199
evt.pageY = data.rollingPageY.at(-1)!;
200
this.dispatchEvent(evt);
201
202
} else if (holdTime >= Gesture.HOLD_DELAY
203
&& Math.abs(data.initialPageX - data.rollingPageX.at(-1)!) < 30
204
&& Math.abs(data.initialPageY - data.rollingPageY.at(-1)!) < 30) {
205
206
const evt = this.newGestureEvent(EventType.Contextmenu, data.initialTarget);
207
evt.pageX = data.rollingPageX.at(-1)!;
208
evt.pageY = data.rollingPageY.at(-1)!;
209
this.dispatchEvent(evt);
210
211
} else if (activeTouchCount === 1) {
212
const finalX = data.rollingPageX.at(-1)!;
213
const finalY = data.rollingPageY.at(-1)!;
214
215
const deltaT = data.rollingTimestamps.at(-1)! - data.rollingTimestamps[0];
216
const deltaX = finalX - data.rollingPageX[0];
217
const deltaY = finalY - data.rollingPageY[0];
218
219
// We need to get all the dispatch targets on the start of the inertia event
220
const dispatchTo = [...this.targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget));
221
this.inertia(targetWindow, dispatchTo, timestamp, // time now
222
Math.abs(deltaX) / deltaT, // speed
223
deltaX > 0 ? 1 : -1, // x direction
224
finalX, // x now
225
Math.abs(deltaY) / deltaT, // y speed
226
deltaY > 0 ? 1 : -1, // y direction
227
finalY // y now
228
);
229
}
230
231
232
this.dispatchEvent(this.newGestureEvent(EventType.End, data.initialTarget));
233
// forget about this touch
234
delete this.activeTouches[touch.identifier];
235
}
236
237
if (this.dispatched) {
238
e.preventDefault();
239
e.stopPropagation();
240
this.dispatched = false;
241
}
242
}
243
244
private newGestureEvent(type: string, initialTarget?: EventTarget): GestureEvent {
245
const event = document.createEvent('CustomEvent') as unknown as GestureEvent;
246
event.initEvent(type, false, true);
247
event.initialTarget = initialTarget;
248
event.tapCount = 0;
249
return event;
250
}
251
252
private dispatchEvent(event: GestureEvent): void {
253
if (event.type === EventType.Tap) {
254
const currentTime = (new Date()).getTime();
255
let setTapCount = 0;
256
if (currentTime - this._lastSetTapCountTime > Gesture.CLEAR_TAP_COUNT_TIME) {
257
setTapCount = 1;
258
} else {
259
setTapCount = 2;
260
}
261
262
this._lastSetTapCountTime = currentTime;
263
event.tapCount = setTapCount;
264
} else if (event.type === EventType.Change || event.type === EventType.Contextmenu) {
265
// tap is canceled by scrolling or context menu
266
this._lastSetTapCountTime = 0;
267
}
268
269
if (event.initialTarget instanceof Node) {
270
for (const ignoreTarget of this.ignoreTargets) {
271
if (ignoreTarget.contains(event.initialTarget)) {
272
return;
273
}
274
}
275
276
const targets: [number, HTMLElement][] = [];
277
for (const target of this.targets) {
278
if (target.contains(event.initialTarget)) {
279
let depth = 0;
280
let now: Node | null = event.initialTarget;
281
while (now && now !== target) {
282
depth++;
283
now = now.parentElement;
284
}
285
targets.push([depth, target]);
286
}
287
}
288
289
targets.sort((a, b) => a[0] - b[0]);
290
291
for (const [_, target] of targets) {
292
target.dispatchEvent(event);
293
this.dispatched = true;
294
}
295
}
296
}
297
298
private inertia(targetWindow: Window, dispatchTo: readonly EventTarget[], t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void {
299
this.handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => {
300
const now = Date.now();
301
302
// velocity: old speed + accel_over_time
303
const deltaT = now - t1;
304
let delta_pos_x = 0, delta_pos_y = 0;
305
let stopped = true;
306
307
vX += Gesture.SCROLL_FRICTION * deltaT;
308
vY += Gesture.SCROLL_FRICTION * deltaT;
309
310
if (vX > 0) {
311
stopped = false;
312
delta_pos_x = dirX * vX * deltaT;
313
}
314
315
if (vY > 0) {
316
stopped = false;
317
delta_pos_y = dirY * vY * deltaT;
318
}
319
320
// dispatch translation event
321
const evt = this.newGestureEvent(EventType.Change);
322
evt.translationX = delta_pos_x;
323
evt.translationY = delta_pos_y;
324
dispatchTo.forEach(d => d.dispatchEvent(evt));
325
326
if (!stopped) {
327
this.inertia(targetWindow, dispatchTo, now, vX, dirX, x + delta_pos_x, vY, dirY, y + delta_pos_y);
328
}
329
});
330
}
331
332
private onTouchMove(e: TouchEvent): void {
333
const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based.
334
335
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
336
337
const touch = e.changedTouches.item(i);
338
339
if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) {
340
console.warn('end of an UNKNOWN touch', touch);
341
continue;
342
}
343
344
const data = this.activeTouches[touch.identifier];
345
346
const evt = this.newGestureEvent(EventType.Change, data.initialTarget);
347
evt.translationX = touch.pageX - data.rollingPageX.at(-1)!;
348
evt.translationY = touch.pageY - data.rollingPageY.at(-1)!;
349
evt.pageX = touch.pageX;
350
evt.pageY = touch.pageY;
351
this.dispatchEvent(evt);
352
353
// only keep a few data points, to average the final speed
354
if (data.rollingPageX.length > 3) {
355
data.rollingPageX.shift();
356
data.rollingPageY.shift();
357
data.rollingTimestamps.shift();
358
}
359
360
data.rollingPageX.push(touch.pageX);
361
data.rollingPageY.push(touch.pageY);
362
data.rollingTimestamps.push(timestamp);
363
}
364
365
if (this.dispatched) {
366
e.preventDefault();
367
e.stopPropagation();
368
this.dispatched = false;
369
}
370
}
371
}
372
373