Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/touch.ts
5243 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
/**
126
* Whether the device is able to represent touch events.
127
*/
128
@memoize
129
static isTouchDevice(): boolean {
130
// `'ontouchstart' in window` always evaluates to true with typescript's modern typings. This causes `window` to be
131
// `never` later in `window.navigator`. That's why we need the explicit `window as Window` cast
132
return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0;
133
}
134
135
/**
136
* Whether the device's primary input is able to hover.
137
*/
138
@memoize
139
static isHoverDevice(): boolean {
140
return mainWindow.matchMedia('(hover: hover)').matches;
141
}
142
143
public override dispose(): void {
144
if (this.handle) {
145
this.handle.dispose();
146
this.handle = null;
147
}
148
149
super.dispose();
150
}
151
152
private onTouchStart(e: TouchEvent): void {
153
const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based.
154
155
if (this.handle) {
156
this.handle.dispose();
157
this.handle = null;
158
}
159
160
for (let i = 0, len = e.targetTouches.length; i < len; i++) {
161
const touch = e.targetTouches.item(i);
162
163
this.activeTouches[touch.identifier] = {
164
id: touch.identifier,
165
initialTarget: touch.target,
166
initialTimeStamp: timestamp,
167
initialPageX: touch.pageX,
168
initialPageY: touch.pageY,
169
rollingTimestamps: [timestamp],
170
rollingPageX: [touch.pageX],
171
rollingPageY: [touch.pageY]
172
};
173
174
const evt = this.newGestureEvent(EventType.Start, touch.target);
175
evt.pageX = touch.pageX;
176
evt.pageY = touch.pageY;
177
this.dispatchEvent(evt);
178
}
179
180
if (this.dispatched) {
181
e.preventDefault();
182
e.stopPropagation();
183
this.dispatched = false;
184
}
185
}
186
187
private onTouchEnd(targetWindow: Window, e: TouchEvent): void {
188
const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based.
189
190
const activeTouchCount = Object.keys(this.activeTouches).length;
191
192
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
193
194
const touch = e.changedTouches.item(i);
195
196
if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) {
197
console.warn('move of an UNKNOWN touch', touch);
198
continue;
199
}
200
201
const data = this.activeTouches[touch.identifier],
202
holdTime = Date.now() - data.initialTimeStamp;
203
204
if (holdTime < Gesture.HOLD_DELAY
205
&& Math.abs(data.initialPageX - data.rollingPageX.at(-1)!) < 30
206
&& Math.abs(data.initialPageY - data.rollingPageY.at(-1)!) < 30) {
207
208
const evt = this.newGestureEvent(EventType.Tap, data.initialTarget);
209
evt.pageX = data.rollingPageX.at(-1)!;
210
evt.pageY = data.rollingPageY.at(-1)!;
211
this.dispatchEvent(evt);
212
213
} else if (holdTime >= Gesture.HOLD_DELAY
214
&& Math.abs(data.initialPageX - data.rollingPageX.at(-1)!) < 30
215
&& Math.abs(data.initialPageY - data.rollingPageY.at(-1)!) < 30) {
216
217
const evt = this.newGestureEvent(EventType.Contextmenu, data.initialTarget);
218
evt.pageX = data.rollingPageX.at(-1)!;
219
evt.pageY = data.rollingPageY.at(-1)!;
220
this.dispatchEvent(evt);
221
222
} else if (activeTouchCount === 1) {
223
const finalX = data.rollingPageX.at(-1)!;
224
const finalY = data.rollingPageY.at(-1)!;
225
226
const deltaT = data.rollingTimestamps.at(-1)! - data.rollingTimestamps[0];
227
const deltaX = finalX - data.rollingPageX[0];
228
const deltaY = finalY - data.rollingPageY[0];
229
230
// We need to get all the dispatch targets on the start of the inertia event
231
const dispatchTo = [...this.targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget));
232
this.inertia(targetWindow, dispatchTo, timestamp, // time now
233
Math.abs(deltaX) / deltaT, // speed
234
deltaX > 0 ? 1 : -1, // x direction
235
finalX, // x now
236
Math.abs(deltaY) / deltaT, // y speed
237
deltaY > 0 ? 1 : -1, // y direction
238
finalY // y now
239
);
240
}
241
242
243
this.dispatchEvent(this.newGestureEvent(EventType.End, data.initialTarget));
244
// forget about this touch
245
delete this.activeTouches[touch.identifier];
246
}
247
248
if (this.dispatched) {
249
e.preventDefault();
250
e.stopPropagation();
251
this.dispatched = false;
252
}
253
}
254
255
private newGestureEvent(type: string, initialTarget?: EventTarget): GestureEvent {
256
const event = document.createEvent('CustomEvent') as unknown as GestureEvent;
257
event.initEvent(type, false, true);
258
event.initialTarget = initialTarget;
259
event.tapCount = 0;
260
return event;
261
}
262
263
private dispatchEvent(event: GestureEvent): void {
264
if (event.type === EventType.Tap) {
265
const currentTime = (new Date()).getTime();
266
let setTapCount = 0;
267
if (currentTime - this._lastSetTapCountTime > Gesture.CLEAR_TAP_COUNT_TIME) {
268
setTapCount = 1;
269
} else {
270
setTapCount = 2;
271
}
272
273
this._lastSetTapCountTime = currentTime;
274
event.tapCount = setTapCount;
275
} else if (event.type === EventType.Change || event.type === EventType.Contextmenu) {
276
// tap is canceled by scrolling or context menu
277
this._lastSetTapCountTime = 0;
278
}
279
280
if (event.initialTarget instanceof Node) {
281
for (const ignoreTarget of this.ignoreTargets) {
282
if (ignoreTarget.contains(event.initialTarget)) {
283
return;
284
}
285
}
286
287
const targets: [number, HTMLElement][] = [];
288
for (const target of this.targets) {
289
if (target.contains(event.initialTarget)) {
290
let depth = 0;
291
let now: Node | null = event.initialTarget;
292
while (now && now !== target) {
293
depth++;
294
now = now.parentElement;
295
}
296
targets.push([depth, target]);
297
}
298
}
299
300
targets.sort((a, b) => a[0] - b[0]);
301
302
for (const [_, target] of targets) {
303
target.dispatchEvent(event);
304
this.dispatched = true;
305
}
306
}
307
}
308
309
private inertia(targetWindow: Window, dispatchTo: readonly EventTarget[], t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void {
310
this.handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => {
311
const now = Date.now();
312
313
// velocity: old speed + accel_over_time
314
const deltaT = now - t1;
315
let delta_pos_x = 0, delta_pos_y = 0;
316
let stopped = true;
317
318
vX += Gesture.SCROLL_FRICTION * deltaT;
319
vY += Gesture.SCROLL_FRICTION * deltaT;
320
321
if (vX > 0) {
322
stopped = false;
323
delta_pos_x = dirX * vX * deltaT;
324
}
325
326
if (vY > 0) {
327
stopped = false;
328
delta_pos_y = dirY * vY * deltaT;
329
}
330
331
// dispatch translation event
332
const evt = this.newGestureEvent(EventType.Change);
333
evt.translationX = delta_pos_x;
334
evt.translationY = delta_pos_y;
335
dispatchTo.forEach(d => d.dispatchEvent(evt));
336
337
if (!stopped) {
338
this.inertia(targetWindow, dispatchTo, now, vX, dirX, x + delta_pos_x, vY, dirY, y + delta_pos_y);
339
}
340
});
341
}
342
343
private onTouchMove(e: TouchEvent): void {
344
const timestamp = Date.now(); // use Date.now() because on FF e.timeStamp is not epoch based.
345
346
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
347
348
const touch = e.changedTouches.item(i);
349
350
if (!this.activeTouches.hasOwnProperty(String(touch.identifier))) {
351
console.warn('end of an UNKNOWN touch', touch);
352
continue;
353
}
354
355
const data = this.activeTouches[touch.identifier];
356
357
const evt = this.newGestureEvent(EventType.Change, data.initialTarget);
358
evt.translationX = touch.pageX - data.rollingPageX.at(-1)!;
359
evt.translationY = touch.pageY - data.rollingPageY.at(-1)!;
360
evt.pageX = touch.pageX;
361
evt.pageY = touch.pageY;
362
this.dispatchEvent(evt);
363
364
// only keep a few data points, to average the final speed
365
if (data.rollingPageX.length > 3) {
366
data.rollingPageX.shift();
367
data.rollingPageY.shift();
368
data.rollingTimestamps.shift();
369
}
370
371
data.rollingPageX.push(touch.pageX);
372
data.rollingPageY.push(touch.pageY);
373
data.rollingTimestamps.push(timestamp);
374
}
375
376
if (this.dispatched) {
377
e.preventDefault();
378
e.stopPropagation();
379
this.dispatched = false;
380
}
381
}
382
}
383
384