2 * noVNC: HTML5 VNC client
3 * Copyright (C) 2020 The noVNC Authors
4 * Licensed under MPL 2.0 (see LICENSE.txt)
6 * See README.md for usage and integration instructions.
10const GH_NOGESTURE = 0;
15const GH_LONGPRESS = 16;
19const GH_INITSTATE = 127;
21const GH_MOVE_THRESHOLD = 50;
22const GH_ANGLE_THRESHOLD = 90; // Degrees
24// Timeout when waiting for gestures (ms)
25const GH_MULTITOUCH_TIMEOUT = 250;
27// Maximum time between press and release for a tap (ms)
28const GH_TAP_TIMEOUT = 1000;
30// Timeout when waiting for longpress (ms)
31const GH_LONGPRESS_TIMEOUT = 1000;
33// Timeout when waiting to decide between PINCH and TWODRAG (ms)
34const GH_TWOTOUCH_TIMEOUT = 50;
36export default class GestureHandler {
40 this._state = GH_INITSTATE;
45 this._waitingRelease = false;
46 this._releaseStart = 0.0;
48 this._longpressTimeoutId = null;
49 this._twoTouchTimeoutId = null;
51 this._boundEventHandler = this._eventHandler.bind(this);
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);
73 this._stopLongpressTimeout();
74 this._stopTwoTouchTimeout();
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);
95 fn = this._touchStart;
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);
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);
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);
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);
148 switch (this._tracked.length) {
150 this._startLongpressTimeout();
154 this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
155 this._stopLongpressTimeout();
159 this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
163 this._state = GH_NOGESTURE;
167 _touchMove(id, x, y) {
168 let touch = this._tracked.find(t => t.id === id);
170 // If this is an update for a touch we're not tracking, ignore it
171 if (touch === undefined) {
175 // Update the touches last position with the event coordinates
179 let deltaX = x - touch.firstX;
180 let deltaY = y - touch.firstY;
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;
188 if (!this._hasDetectedGesture()) {
189 // Ignore moves smaller than the minimum threshold
190 if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
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();
198 if (this._tracked.length !== 1) {
199 this._state &= ~(GH_DRAG);
201 if (this._tracked.length !== 2) {
202 this._state &= ~(GH_TWODRAG | GH_PINCH);
205 // We need to figure out which of our different two touch gestures
207 if (this._tracked.length === 2) {
209 // The other touch is the one where the id doesn't match
210 let prevTouch = this._tracked.find(t => t.id !== id);
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);
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) {
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);
225 // PINCH or TWODRAG can be eliminated depending on the angle
226 if (deltaAngle > GH_ANGLE_THRESHOLD) {
227 this._state &= ~GH_TWODRAG;
229 this._state &= ~GH_PINCH;
232 if (this._isTwoTouchTimeoutRunning()) {
233 this._stopTwoTouchTimeout();
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();
242 if (!this._hasDetectedGesture()) {
246 this._pushEvent('gesturestart');
249 this._pushEvent('gesturemove');
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);
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;
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;
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();
283 if (!this._waitingRelease) {
284 this._releaseStart = Date.now();
285 this._waitingRelease = true;
287 // Can't be a tap that requires more touches than we current have
288 switch (this._tracked.length) {
290 this._state &= ~(GH_TWOTAP | GH_THREETAP);
294 this._state &= ~(GH_ONETAP | GH_THREETAP);
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;
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;
312 let touch = this._tracked.find(t => t.id === id);
313 touch.active = false;
315 // Are we still waiting for more releases?
316 if (this._hasDetectedGesture()) {
317 this._pushEvent('gesturestart');
319 // Have we reached a dead end?
320 if (this._state !== GH_NOGESTURE) {
326 if (this._hasDetectedGesture()) {
327 this._pushEvent('gestureend');
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);
338 this._state = GH_NOGESTURE;
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);
345 // We reset the state if ignored is empty
346 if ((this._ignored.length === 0)) {
347 this._state = GH_INITSTATE;
348 this._waitingRelease = false;
352 _hasDetectedGesture() {
353 if (this._state === GH_NOGESTURE) {
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)) {
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)) {
373 _startLongpressTimeout() {
374 this._stopLongpressTimeout();
375 this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),
376 GH_LONGPRESS_TIMEOUT);
379 _stopLongpressTimeout() {
380 clearTimeout(this._longpressTimeoutId);
381 this._longpressTimeoutId = null;
384 _longpressTimeout() {
385 if (this._hasDetectedGesture()) {
386 throw new Error("A longpress gesture failed, conflict with a different gesture");
389 this._state = GH_LONGPRESS;
390 this._pushEvent('gesturestart');
393 _startTwoTouchTimeout() {
394 this._stopTwoTouchTimeout();
395 this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),
396 GH_TWOTOUCH_TIMEOUT);
399 _stopTwoTouchTimeout() {
400 clearTimeout(this._twoTouchTimeoutId);
401 this._twoTouchTimeoutId = null;
404 _isTwoTouchTimeoutRunning() {
405 return this._twoTouchTimeoutId !== null;
409 if (this._tracked.length === 0) {
410 throw new Error("A pinch or two drag gesture failed, no tracked touches");
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);
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));
424 if ((avgMoveV < deltaTouchDistance) &&
425 (avgMoveH < deltaTouchDistance)) {
426 this._state = GH_PINCH;
428 this._state = GH_TWODRAG;
431 this._pushEvent('gesturestart');
432 this._pushEvent('gesturemove');
436 let detail = { type: this._stateToGesture(this._state) };
438 // For most gesture events the current (average) position is the
440 let avg = this._getPosition();
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') {
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) {
458 detail['clientX'] = pos.x;
459 detail['clientY'] = pos.y;
461 // FIXME: other coordinates?
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;
470 detail['magnitudeX'] = distance.last.x;
471 detail['magnitudeY'] = distance.last.y;
473 } else if (this._state === GH_TWODRAG) {
474 if (type === 'gesturestart') {
475 detail['magnitudeX'] = 0.0;
476 detail['magnitudeY'] = 0.0;
478 let movement = this._getAverageMovement();
479 detail['magnitudeX'] = movement.x;
480 detail['magnitudeY'] = movement.y;
484 let gev = new CustomEvent(type, { detail: detail });
485 this._target.dispatchEvent(gev);
488 _stateToGesture(state) {
506 throw new Error("Unknown gesture state: " + state);
510 if (this._tracked.length === 0) {
511 throw new Error("Failed to get gesture position, no tracked touches");
514 let size = this._tracked.length;
515 let fx = 0, fy = 0, lx = 0, ly = 0;
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;
524 return { first: { x: fx / size,
526 last: { x: lx / size,
530 _getAverageMovement() {
531 if (this._tracked.length === 0) {
532 throw new Error("Failed to get gesture movement, no tracked touches");
537 let size = this._tracked.length;
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;
544 return { x: totalH / size,
548 _getAverageDistance() {
549 if (this._tracked.length === 0) {
550 throw new Error("Failed to get gesture distance, no tracked touches");
553 // Distance between the first and last tracked touches
555 let first = this._tracked[0];
556 let last = this._tracked[this._tracked.length - 1];
558 let fdx = Math.abs(last.firstX - first.firstX);
559 let fdy = Math.abs(last.firstY - first.firstY);
561 let ldx = Math.abs(last.lastX - first.lastX);
562 let ldy = Math.abs(last.lastY - first.lastY);
564 return { first: { x: fdx, y: fdy },
565 last: { x: ldx, y: ldy } };