main
Raw Download raw file
  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}