main
1/*
2 * noVNC: HTML5 VNC client
3 * Copyright (C) 2020 The noVNC authors
4 * Licensed under MPL 2.0 (see LICENSE.txt)
5 *
6 * See README.md for usage and integration instructions.
7 *
8 */
9
10const GH_NOGESTURE = 0;
11const GH_ONETAP = 1;
12const GH_TWOTAP = 2;
13const GH_THREETAP = 4;
14const GH_DRAG = 8;
15const GH_LONGPRESS = 16;
16const GH_TWODRAG = 32;
17const GH_PINCH = 64;
18
19const GH_INITSTATE = 127;
20
21const GH_MOVE_THRESHOLD = 50;
22const GH_ANGLE_THRESHOLD = 90; // Degrees
23
24// Timeout when waiting for gestures (ms)
25const GH_MULTITOUCH_TIMEOUT = 250;
26
27// Maximum time between press and release for a tap (ms)
28const GH_TAP_TIMEOUT = 1000;
29
30// Timeout when waiting for longpress (ms)
31const GH_LONGPRESS_TIMEOUT = 1000;
32
33// Timeout when waiting to decide between PINCH and TWODRAG (ms)
34const GH_TWOTOUCH_TIMEOUT = 50;
35
36export default class GestureHandler {
37 constructor() {
38 this._target = null;
39
40 this._state = GH_INITSTATE;
41
42 this._tracked = [];
43 this._ignored = [];
44
45 this._waitingRelease = false;
46 this._releaseStart = 0.0;
47
48 this._longpressTimeoutId = null;
49 this._twoTouchTimeoutId = null;
50
51 this._boundEventHandler = this._eventHandler.bind(this);
52 }
53
54 attach(target) {
55 this.detach();
56
57 this._target = target;
58 this._target.addEventListener('touchstart',
59 this._boundEventHandler);
60 this._target.addEventListener('touchmove',
61 this._boundEventHandler);
62 this._target.addEventListener('touchend',
63 this._boundEventHandler);
64 this._target.addEventListener('touchcancel',
65 this._boundEventHandler);
66 }
67
68 detach() {
69 if (!this._target) {
70 return;
71 }
72
73 this._stopLongpressTimeout();
74 this._stopTwoTouchTimeout();
75
76 this._target.removeEventListener('touchstart',
77 this._boundEventHandler);
78 this._target.removeEventListener('touchmove',
79 this._boundEventHandler);
80 this._target.removeEventListener('touchend',
81 this._boundEventHandler);
82 this._target.removeEventListener('touchcancel',
83 this._boundEventHandler);
84 this._target = null;
85 }
86
87 _eventHandler(e) {
88 let fn;
89
90 e.stopPropagation();
91 e.preventDefault();
92
93 switch (e.type) {
94 case 'touchstart':
95 fn = this._touchStart;
96 break;
97 case 'touchmove':
98 fn = this._touchMove;
99 break;
100 case 'touchend':
101 case 'touchcancel':
102 fn = this._touchEnd;
103 break;
104 }
105
106 for (let i = 0; i < e.changedTouches.length; i++) {
107 let touch = e.changedTouches[i];
108 fn.call(this, touch.identifier, touch.clientX, touch.clientY);
109 }
110 }
111
112 _touchStart(id, x, y) {
113 // Ignore any new touches if there is already an active gesture,
114 // or we're in a cleanup state
115 if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) {
116 this._ignored.push(id);
117 return;
118 }
119
120 // Did it take too long between touches that we should no longer
121 // consider this a single gesture?
122 if ((this._tracked.length > 0) &&
123 ((Date.now() - this._tracked[0].started) > GH_MULTITOUCH_TIMEOUT)) {
124 this._state = GH_NOGESTURE;
125 this._ignored.push(id);
126 return;
127 }
128
129 // If we're waiting for fingers to release then we should no longer
130 // recognize new touches
131 if (this._waitingRelease) {
132 this._state = GH_NOGESTURE;
133 this._ignored.push(id);
134 return;
135 }
136
137 this._tracked.push({
138 id: id,
139 started: Date.now(),
140 active: true,
141 firstX: x,
142 firstY: y,
143 lastX: x,
144 lastY: y,
145 angle: 0
146 });
147
148 switch (this._tracked.length) {
149 case 1:
150 this._startLongpressTimeout();
151 break;
152
153 case 2:
154 this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
155 this._stopLongpressTimeout();
156 break;
157
158 case 3:
159 this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
160 break;
161
162 default:
163 this._state = GH_NOGESTURE;
164 }
165 }
166
167 _touchMove(id, x, y) {
168 let touch = this._tracked.find(t => t.id === id);
169
170 // If this is an update for a touch we're not tracking, ignore it
171 if (touch === undefined) {
172 return;
173 }
174
175 // Update the touches last position with the event coordinates
176 touch.lastX = x;
177 touch.lastY = y;
178
179 let deltaX = x - touch.firstX;
180 let deltaY = y - touch.firstY;
181
182 // Update angle when the touch has moved
183 if ((touch.firstX !== touch.lastX) ||
184 (touch.firstY !== touch.lastY)) {
185 touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
186 }
187
188 if (!this._hasDetectedGesture()) {
189 // Ignore moves smaller than the minimum threshold
190 if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
191 return;
192 }
193
194 // Can't be a tap or long press as we've seen movement
195 this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);
196 this._stopLongpressTimeout();
197
198 if (this._tracked.length !== 1) {
199 this._state &= ~(GH_DRAG);
200 }
201 if (this._tracked.length !== 2) {
202 this._state &= ~(GH_TWODRAG | GH_PINCH);
203 }
204
205 // We need to figure out which of our different two touch gestures
206 // this might be
207 if (this._tracked.length === 2) {
208
209 // The other touch is the one where the id doesn't match
210 let prevTouch = this._tracked.find(t => t.id !== id);
211
212 // How far the previous touch point has moved since start
213 let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX,
214 prevTouch.firstY - prevTouch.lastY);
215
216 // We know that the current touch moved far enough,
217 // but unless both touches moved further than their
218 // threshold we don't want to disqualify any gestures
219 if (prevDeltaMove > GH_MOVE_THRESHOLD) {
220
221 // The angle difference between the direction of the touch points
222 let deltaAngle = Math.abs(touch.angle - prevTouch.angle);
223 deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);
224
225 // PINCH or TWODRAG can be eliminated depending on the angle
226 if (deltaAngle > GH_ANGLE_THRESHOLD) {
227 this._state &= ~GH_TWODRAG;
228 } else {
229 this._state &= ~GH_PINCH;
230 }
231
232 if (this._isTwoTouchTimeoutRunning()) {
233 this._stopTwoTouchTimeout();
234 }
235 } else if (!this._isTwoTouchTimeoutRunning()) {
236 // We can't determine the gesture right now, let's
237 // wait and see if more events are on their way
238 this._startTwoTouchTimeout();
239 }
240 }
241
242 if (!this._hasDetectedGesture()) {
243 return;
244 }
245
246 this._pushEvent('gesturestart');
247 }
248
249 this._pushEvent('gesturemove');
250 }
251
252 _touchEnd(id, x, y) {
253 // Check if this is an ignored touch
254 if (this._ignored.indexOf(id) !== -1) {
255 // Remove this touch from ignored
256 this._ignored.splice(this._ignored.indexOf(id), 1);
257
258 // And reset the state if there are no more touches
259 if ((this._ignored.length === 0) &&
260 (this._tracked.length === 0)) {
261 this._state = GH_INITSTATE;
262 this._waitingRelease = false;
263 }
264 return;
265 }
266
267 // We got a touchend before the timer triggered,
268 // this cannot result in a gesture anymore.
269 if (!this._hasDetectedGesture() &&
270 this._isTwoTouchTimeoutRunning()) {
271 this._stopTwoTouchTimeout();
272 this._state = GH_NOGESTURE;
273 }
274
275 // Some gestures don't trigger until a touch is released
276 if (!this._hasDetectedGesture()) {
277 // Can't be a gesture that relies on movement
278 this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);
279 // Or something that relies on more time
280 this._state &= ~GH_LONGPRESS;
281 this._stopLongpressTimeout();
282
283 if (!this._waitingRelease) {
284 this._releaseStart = Date.now();
285 this._waitingRelease = true;
286
287 // Can't be a tap that requires more touches than we current have
288 switch (this._tracked.length) {
289 case 1:
290 this._state &= ~(GH_TWOTAP | GH_THREETAP);
291 break;
292
293 case 2:
294 this._state &= ~(GH_ONETAP | GH_THREETAP);
295 break;
296 }
297 }
298 }
299
300 // Waiting for all touches to release? (i.e. some tap)
301 if (this._waitingRelease) {
302 // Were all touches released at roughly the same time?
303 if ((Date.now() - this._releaseStart) > GH_MULTITOUCH_TIMEOUT) {
304 this._state = GH_NOGESTURE;
305 }
306
307 // Did too long time pass between press and release?
308 if (this._tracked.some(t => (Date.now() - t.started) > GH_TAP_TIMEOUT)) {
309 this._state = GH_NOGESTURE;
310 }
311
312 let touch = this._tracked.find(t => t.id === id);
313 touch.active = false;
314
315 // Are we still waiting for more releases?
316 if (this._hasDetectedGesture()) {
317 this._pushEvent('gesturestart');
318 } else {
319 // Have we reached a dead end?
320 if (this._state !== GH_NOGESTURE) {
321 return;
322 }
323 }
324 }
325
326 if (this._hasDetectedGesture()) {
327 this._pushEvent('gestureend');
328 }
329
330 // Ignore any remaining touches until they are ended
331 for (let i = 0; i < this._tracked.length; i++) {
332 if (this._tracked[i].active) {
333 this._ignored.push(this._tracked[i].id);
334 }
335 }
336 this._tracked = [];
337
338 this._state = GH_NOGESTURE;
339
340 // Remove this touch from ignored if it's in there
341 if (this._ignored.indexOf(id) !== -1) {
342 this._ignored.splice(this._ignored.indexOf(id), 1);
343 }
344
345 // We reset the state if ignored is empty
346 if ((this._ignored.length === 0)) {
347 this._state = GH_INITSTATE;
348 this._waitingRelease = false;
349 }
350 }
351
352 _hasDetectedGesture() {
353 if (this._state === GH_NOGESTURE) {
354 return false;
355 }
356 // Check to see if the bitmask value is a power of 2
357 // (i.e. only one bit set). If it is, we have a state.
358 if (this._state & (this._state - 1)) {
359 return false;
360 }
361
362 // For taps we also need to have all touches released
363 // before we've fully detected the gesture
364 if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {
365 if (this._tracked.some(t => t.active)) {
366 return false;
367 }
368 }
369
370 return true;
371 }
372
373 _startLongpressTimeout() {
374 this._stopLongpressTimeout();
375 this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),
376 GH_LONGPRESS_TIMEOUT);
377 }
378
379 _stopLongpressTimeout() {
380 clearTimeout(this._longpressTimeoutId);
381 this._longpressTimeoutId = null;
382 }
383
384 _longpressTimeout() {
385 if (this._hasDetectedGesture()) {
386 throw new Error("A longpress gesture failed, conflict with a different gesture");
387 }
388
389 this._state = GH_LONGPRESS;
390 this._pushEvent('gesturestart');
391 }
392
393 _startTwoTouchTimeout() {
394 this._stopTwoTouchTimeout();
395 this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),
396 GH_TWOTOUCH_TIMEOUT);
397 }
398
399 _stopTwoTouchTimeout() {
400 clearTimeout(this._twoTouchTimeoutId);
401 this._twoTouchTimeoutId = null;
402 }
403
404 _isTwoTouchTimeoutRunning() {
405 return this._twoTouchTimeoutId !== null;
406 }
407
408 _twoTouchTimeout() {
409 if (this._tracked.length === 0) {
410 throw new Error("A pinch or two drag gesture failed, no tracked touches");
411 }
412
413 // How far each touch point has moved since start
414 let avgM = this._getAverageMovement();
415 let avgMoveH = Math.abs(avgM.x);
416 let avgMoveV = Math.abs(avgM.y);
417
418 // The difference in the distance between where
419 // the touch points started and where they are now
420 let avgD = this._getAverageDistance();
421 let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) -
422 Math.hypot(avgD.last.x, avgD.last.y));
423
424 if ((avgMoveV < deltaTouchDistance) &&
425 (avgMoveH < deltaTouchDistance)) {
426 this._state = GH_PINCH;
427 } else {
428 this._state = GH_TWODRAG;
429 }
430
431 this._pushEvent('gesturestart');
432 this._pushEvent('gesturemove');
433 }
434
435 _pushEvent(type) {
436 let detail = { type: this._stateToGesture(this._state) };
437
438 // For most gesture events the current (average) position is the
439 // most useful
440 let avg = this._getPosition();
441 let pos = avg.last;
442
443 // However we have a slight distance to detect gestures, so for the
444 // first gesture event we want to use the first positions we saw
445 if (type === 'gesturestart') {
446 pos = avg.first;
447 }
448
449 // For these gestures, we always want the event coordinates
450 // to be where the gesture began, not the current touch location.
451 switch (this._state) {
452 case GH_TWODRAG:
453 case GH_PINCH:
454 pos = avg.first;
455 break;
456 }
457
458 detail['clientX'] = pos.x;
459 detail['clientY'] = pos.y;
460
461 // FIXME: other coordinates?
462
463 // Some gestures also have a magnitude
464 if (this._state === GH_PINCH) {
465 let distance = this._getAverageDistance();
466 if (type === 'gesturestart') {
467 detail['magnitudeX'] = distance.first.x;
468 detail['magnitudeY'] = distance.first.y;
469 } else {
470 detail['magnitudeX'] = distance.last.x;
471 detail['magnitudeY'] = distance.last.y;
472 }
473 } else if (this._state === GH_TWODRAG) {
474 if (type === 'gesturestart') {
475 detail['magnitudeX'] = 0.0;
476 detail['magnitudeY'] = 0.0;
477 } else {
478 let movement = this._getAverageMovement();
479 detail['magnitudeX'] = movement.x;
480 detail['magnitudeY'] = movement.y;
481 }
482 }
483
484 let gev = new CustomEvent(type, { detail: detail });
485 this._target.dispatchEvent(gev);
486 }
487
488 _stateToGesture(state) {
489 switch (state) {
490 case GH_ONETAP:
491 return 'onetap';
492 case GH_TWOTAP:
493 return 'twotap';
494 case GH_THREETAP:
495 return 'threetap';
496 case GH_DRAG:
497 return 'drag';
498 case GH_LONGPRESS:
499 return 'longpress';
500 case GH_TWODRAG:
501 return 'twodrag';
502 case GH_PINCH:
503 return 'pinch';
504 }
505
506 throw new Error("Unknown gesture state: " + state);
507 }
508
509 _getPosition() {
510 if (this._tracked.length === 0) {
511 throw new Error("Failed to get gesture position, no tracked touches");
512 }
513
514 let size = this._tracked.length;
515 let fx = 0, fy = 0, lx = 0, ly = 0;
516
517 for (let i = 0; i < this._tracked.length; i++) {
518 fx += this._tracked[i].firstX;
519 fy += this._tracked[i].firstY;
520 lx += this._tracked[i].lastX;
521 ly += this._tracked[i].lastY;
522 }
523
524 return { first: { x: fx / size,
525 y: fy / size },
526 last: { x: lx / size,
527 y: ly / size } };
528 }
529
530 _getAverageMovement() {
531 if (this._tracked.length === 0) {
532 throw new Error("Failed to get gesture movement, no tracked touches");
533 }
534
535 let totalH, totalV;
536 totalH = totalV = 0;
537 let size = this._tracked.length;
538
539 for (let i = 0; i < this._tracked.length; i++) {
540 totalH += this._tracked[i].lastX - this._tracked[i].firstX;
541 totalV += this._tracked[i].lastY - this._tracked[i].firstY;
542 }
543
544 return { x: totalH / size,
545 y: totalV / size };
546 }
547
548 _getAverageDistance() {
549 if (this._tracked.length === 0) {
550 throw new Error("Failed to get gesture distance, no tracked touches");
551 }
552
553 // Distance between the first and last tracked touches
554
555 let first = this._tracked[0];
556 let last = this._tracked[this._tracked.length - 1];
557
558 let fdx = Math.abs(last.firstX - first.firstX);
559 let fdy = Math.abs(last.firstY - first.firstY);
560
561 let ldx = Math.abs(last.lastX - first.lastX);
562 let ldy = Math.abs(last.lastY - first.lastY);
563
564 return { first: { x: fdx, y: fdy },
565 last: { x: ldx, y: ldy } };
566 }
567}