/* * noVNC: HTML5 VNC client * Copyright (C) 2020 The noVNC Authors * Licensed under MPL 2.0 (see LICENSE.txt) * * See README.md for usage and integration instructions. * */ const GH_NOGESTURE = 0; const GH_ONETAP = 1; const GH_TWOTAP = 2; const GH_THREETAP = 4; const GH_DRAG = 8; const GH_LONGPRESS = 16; const GH_TWODRAG = 32; const GH_PINCH = 64; const GH_INITSTATE = 127; const GH_MOVE_THRESHOLD = 50; const GH_ANGLE_THRESHOLD = 90; // Degrees // Timeout when waiting for gestures (ms) const GH_MULTITOUCH_TIMEOUT = 250; // Maximum time between press and release for a tap (ms) const GH_TAP_TIMEOUT = 1000; // Timeout when waiting for longpress (ms) const GH_LONGPRESS_TIMEOUT = 1000; // Timeout when waiting to decide between PINCH and TWODRAG (ms) const GH_TWOTOUCH_TIMEOUT = 50; export default class GestureHandler { constructor() { this._target = null; this._state = GH_INITSTATE; this._tracked = []; this._ignored = []; this._waitingRelease = false; this._releaseStart = 0.0; this._longpressTimeoutId = null; this._twoTouchTimeoutId = null; this._boundEventHandler = this._eventHandler.bind(this); } attach(target) { this.detach(); this._target = target; this._target.addEventListener('touchstart', this._boundEventHandler); this._target.addEventListener('touchmove', this._boundEventHandler); this._target.addEventListener('touchend', this._boundEventHandler); this._target.addEventListener('touchcancel', this._boundEventHandler); } detach() { if (!this._target) { return; } this._stopLongpressTimeout(); this._stopTwoTouchTimeout(); this._target.removeEventListener('touchstart', this._boundEventHandler); this._target.removeEventListener('touchmove', this._boundEventHandler); this._target.removeEventListener('touchend', this._boundEventHandler); this._target.removeEventListener('touchcancel', this._boundEventHandler); this._target = null; } _eventHandler(e) { let fn; e.stopPropagation(); e.preventDefault(); switch (e.type) { case 'touchstart': fn = this._touchStart; break; case 'touchmove': fn = this._touchMove; break; case 'touchend': case 'touchcancel': fn = this._touchEnd; break; } for (let i = 0; i < e.changedTouches.length; i++) { let touch = e.changedTouches[i]; fn.call(this, touch.identifier, touch.clientX, touch.clientY); } } _touchStart(id, x, y) { // Ignore any new touches if there is already an active gesture, // or we're in a cleanup state if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) { this._ignored.push(id); return; } // Did it take too long between touches that we should no longer // consider this a single gesture? if ((this._tracked.length > 0) && ((Date.now() - this._tracked[0].started) > GH_MULTITOUCH_TIMEOUT)) { this._state = GH_NOGESTURE; this._ignored.push(id); return; } // If we're waiting for fingers to release then we should no longer // recognize new touches if (this._waitingRelease) { this._state = GH_NOGESTURE; this._ignored.push(id); return; } this._tracked.push({ id: id, started: Date.now(), active: true, firstX: x, firstY: y, lastX: x, lastY: y, angle: 0 }); switch (this._tracked.length) { case 1: this._startLongpressTimeout(); break; case 2: this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS); this._stopLongpressTimeout(); break; case 3: this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH); break; default: this._state = GH_NOGESTURE; } } _touchMove(id, x, y) { let touch = this._tracked.find(t => t.id === id); // If this is an update for a touch we're not tracking, ignore it if (touch === undefined) { return; } // Update the touches last position with the event coordinates touch.lastX = x; touch.lastY = y; let deltaX = x - touch.firstX; let deltaY = y - touch.firstY; // Update angle when the touch has moved if ((touch.firstX !== touch.lastX) || (touch.firstY !== touch.lastY)) { touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI; } if (!this._hasDetectedGesture()) { // Ignore moves smaller than the minimum threshold if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) { return; } // Can't be a tap or long press as we've seen movement this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS); this._stopLongpressTimeout(); if (this._tracked.length !== 1) { this._state &= ~(GH_DRAG); } if (this._tracked.length !== 2) { this._state &= ~(GH_TWODRAG | GH_PINCH); } // We need to figure out which of our different two touch gestures // this might be if (this._tracked.length === 2) { // The other touch is the one where the id doesn't match let prevTouch = this._tracked.find(t => t.id !== id); // How far the previous touch point has moved since start let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX, prevTouch.firstY - prevTouch.lastY); // We know that the current touch moved far enough, // but unless both touches moved further than their // threshold we don't want to disqualify any gestures if (prevDeltaMove > GH_MOVE_THRESHOLD) { // The angle difference between the direction of the touch points let deltaAngle = Math.abs(touch.angle - prevTouch.angle); deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180); // PINCH or TWODRAG can be eliminated depending on the angle if (deltaAngle > GH_ANGLE_THRESHOLD) { this._state &= ~GH_TWODRAG; } else { this._state &= ~GH_PINCH; } if (this._isTwoTouchTimeoutRunning()) { this._stopTwoTouchTimeout(); } } else if (!this._isTwoTouchTimeoutRunning()) { // We can't determine the gesture right now, let's // wait and see if more events are on their way this._startTwoTouchTimeout(); } } if (!this._hasDetectedGesture()) { return; } this._pushEvent('gesturestart'); } this._pushEvent('gesturemove'); } _touchEnd(id, x, y) { // Check if this is an ignored touch if (this._ignored.indexOf(id) !== -1) { // Remove this touch from ignored this._ignored.splice(this._ignored.indexOf(id), 1); // And reset the state if there are no more touches if ((this._ignored.length === 0) && (this._tracked.length === 0)) { this._state = GH_INITSTATE; this._waitingRelease = false; } return; } // We got a touchend before the timer triggered, // this cannot result in a gesture anymore. if (!this._hasDetectedGesture() && this._isTwoTouchTimeoutRunning()) { this._stopTwoTouchTimeout(); this._state = GH_NOGESTURE; } // Some gestures don't trigger until a touch is released if (!this._hasDetectedGesture()) { // Can't be a gesture that relies on movement this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH); // Or something that relies on more time this._state &= ~GH_LONGPRESS; this._stopLongpressTimeout(); if (!this._waitingRelease) { this._releaseStart = Date.now(); this._waitingRelease = true; // Can't be a tap that requires more touches than we current have switch (this._tracked.length) { case 1: this._state &= ~(GH_TWOTAP | GH_THREETAP); break; case 2: this._state &= ~(GH_ONETAP | GH_THREETAP); break; } } } // Waiting for all touches to release? (i.e. some tap) if (this._waitingRelease) { // Were all touches released at roughly the same time? if ((Date.now() - this._releaseStart) > GH_MULTITOUCH_TIMEOUT) { this._state = GH_NOGESTURE; } // Did too long time pass between press and release? if (this._tracked.some(t => (Date.now() - t.started) > GH_TAP_TIMEOUT)) { this._state = GH_NOGESTURE; } let touch = this._tracked.find(t => t.id === id); touch.active = false; // Are we still waiting for more releases? if (this._hasDetectedGesture()) { this._pushEvent('gesturestart'); } else { // Have we reached a dead end? if (this._state !== GH_NOGESTURE) { return; } } } if (this._hasDetectedGesture()) { this._pushEvent('gestureend'); } // Ignore any remaining touches until they are ended for (let i = 0; i < this._tracked.length; i++) { if (this._tracked[i].active) { this._ignored.push(this._tracked[i].id); } } this._tracked = []; this._state = GH_NOGESTURE; // Remove this touch from ignored if it's in there if (this._ignored.indexOf(id) !== -1) { this._ignored.splice(this._ignored.indexOf(id), 1); } // We reset the state if ignored is empty if ((this._ignored.length === 0)) { this._state = GH_INITSTATE; this._waitingRelease = false; } } _hasDetectedGesture() { if (this._state === GH_NOGESTURE) { return false; } // Check to see if the bitmask value is a power of 2 // (i.e. only one bit set). If it is, we have a state. if (this._state & (this._state - 1)) { return false; } // For taps we also need to have all touches released // before we've fully detected the gesture if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) { if (this._tracked.some(t => t.active)) { return false; } } return true; } _startLongpressTimeout() { this._stopLongpressTimeout(); this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(), GH_LONGPRESS_TIMEOUT); } _stopLongpressTimeout() { clearTimeout(this._longpressTimeoutId); this._longpressTimeoutId = null; } _longpressTimeout() { if (this._hasDetectedGesture()) { throw new Error("A longpress gesture failed, conflict with a different gesture"); } this._state = GH_LONGPRESS; this._pushEvent('gesturestart'); } _startTwoTouchTimeout() { this._stopTwoTouchTimeout(); this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(), GH_TWOTOUCH_TIMEOUT); } _stopTwoTouchTimeout() { clearTimeout(this._twoTouchTimeoutId); this._twoTouchTimeoutId = null; } _isTwoTouchTimeoutRunning() { return this._twoTouchTimeoutId !== null; } _twoTouchTimeout() { if (this._tracked.length === 0) { throw new Error("A pinch or two drag gesture failed, no tracked touches"); } // How far each touch point has moved since start let avgM = this._getAverageMovement(); let avgMoveH = Math.abs(avgM.x); let avgMoveV = Math.abs(avgM.y); // The difference in the distance between where // the touch points started and where they are now let avgD = this._getAverageDistance(); let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) - Math.hypot(avgD.last.x, avgD.last.y)); if ((avgMoveV < deltaTouchDistance) && (avgMoveH < deltaTouchDistance)) { this._state = GH_PINCH; } else { this._state = GH_TWODRAG; } this._pushEvent('gesturestart'); this._pushEvent('gesturemove'); } _pushEvent(type) { let detail = { type: this._stateToGesture(this._state) }; // For most gesture events the current (average) position is the // most useful let avg = this._getPosition(); let pos = avg.last; // However we have a slight distance to detect gestures, so for the // first gesture event we want to use the first positions we saw if (type === 'gesturestart') { pos = avg.first; } // For these gestures, we always want the event coordinates // to be where the gesture began, not the current touch location. switch (this._state) { case GH_TWODRAG: case GH_PINCH: pos = avg.first; break; } detail['clientX'] = pos.x; detail['clientY'] = pos.y; // FIXME: other coordinates? // Some gestures also have a magnitude if (this._state === GH_PINCH) { let distance = this._getAverageDistance(); if (type === 'gesturestart') { detail['magnitudeX'] = distance.first.x; detail['magnitudeY'] = distance.first.y; } else { detail['magnitudeX'] = distance.last.x; detail['magnitudeY'] = distance.last.y; } } else if (this._state === GH_TWODRAG) { if (type === 'gesturestart') { detail['magnitudeX'] = 0.0; detail['magnitudeY'] = 0.0; } else { let movement = this._getAverageMovement(); detail['magnitudeX'] = movement.x; detail['magnitudeY'] = movement.y; } } let gev = new CustomEvent(type, { detail: detail }); this._target.dispatchEvent(gev); } _stateToGesture(state) { switch (state) { case GH_ONETAP: return 'onetap'; case GH_TWOTAP: return 'twotap'; case GH_THREETAP: return 'threetap'; case GH_DRAG: return 'drag'; case GH_LONGPRESS: return 'longpress'; case GH_TWODRAG: return 'twodrag'; case GH_PINCH: return 'pinch'; } throw new Error("Unknown gesture state: " + state); } _getPosition() { if (this._tracked.length === 0) { throw new Error("Failed to get gesture position, no tracked touches"); } let size = this._tracked.length; let fx = 0, fy = 0, lx = 0, ly = 0; for (let i = 0; i < this._tracked.length; i++) { fx += this._tracked[i].firstX; fy += this._tracked[i].firstY; lx += this._tracked[i].lastX; ly += this._tracked[i].lastY; } return { first: { x: fx / size, y: fy / size }, last: { x: lx / size, y: ly / size } }; } _getAverageMovement() { if (this._tracked.length === 0) { throw new Error("Failed to get gesture movement, no tracked touches"); } let totalH, totalV; totalH = totalV = 0; let size = this._tracked.length; for (let i = 0; i < this._tracked.length; i++) { totalH += this._tracked[i].lastX - this._tracked[i].firstX; totalV += this._tracked[i].lastY - this._tracked[i].firstY; } return { x: totalH / size, y: totalV / size }; } _getAverageDistance() { if (this._tracked.length === 0) { throw new Error("Failed to get gesture distance, no tracked touches"); } // Distance between the first and last tracked touches let first = this._tracked[0]; let last = this._tracked[this._tracked.length - 1]; let fdx = Math.abs(last.firstX - first.firstX); let fdy = Math.abs(last.firstY - first.firstY); let ldx = Math.abs(last.lastX - first.lastX); let ldy = Math.abs(last.lastY - first.lastY); return { first: { x: fdx, y: fdy }, last: { x: ldx, y: ldy } }; } }