JavaScript 65.5%
Python 17.8%
CSS 13%
HTML 3.7%
1/*2 * noVNC: HTML5 VNC client3 * Copyright (C) 2020 The noVNC authors4 * Licensed under MPL 2.0 (see LICENSE.txt)5 *6 * See README.md for usage and integration instructions.7 *8 */910const 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;1819const GH_INITSTATE = 127;2021const GH_MOVE_THRESHOLD = 50;22const GH_ANGLE_THRESHOLD = 90; // Degrees2324// Timeout when waiting for gestures (ms)25const GH_MULTITOUCH_TIMEOUT = 250;2627// Maximum time between press and release for a tap (ms)28const GH_TAP_TIMEOUT = 1000;2930// Timeout when waiting for longpress (ms)31const GH_LONGPRESS_TIMEOUT = 1000;3233// Timeout when waiting to decide between PINCH and TWODRAG (ms)34const GH_TWOTOUCH_TIMEOUT = 50;3536export default class GestureHandler {37 constructor() {38 this._target = null;3940 this._state = GH_INITSTATE;4142 this._tracked = [];43 this._ignored = [];4445 this._waitingRelease = false;46 this._releaseStart = 0.0;4748 this._longpressTimeoutId = null;49 this._twoTouchTimeoutId = null;5051 this._boundEventHandler = this._eventHandler.bind(this);52 }5354 attach(target) {55 this.detach();5657 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 }6768 detach() {69 if (!this._target) {70 return;71 }7273 this._stopLongpressTimeout();74 this._stopTwoTouchTimeout();7576 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 }8687 _eventHandler(e) {88 let fn;8990 e.stopPropagation();91 e.preventDefault();9293 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 }105106 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 }111112 _touchStart(id, x, y) {113 // Ignore any new touches if there is already an active gesture,114 // or we're in a cleanup state115 if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) {116 this._ignored.push(id);117 return;118 }119120 // Did it take too long between touches that we should no longer121 // 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 }128129 // If we're waiting for fingers to release then we should no longer130 // recognize new touches131 if (this._waitingRelease) {132 this._state = GH_NOGESTURE;133 this._ignored.push(id);134 return;135 }136137 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: 0146 });147148 switch (this._tracked.length) {149 case 1:150 this._startLongpressTimeout();151 break;152153 case 2:154 this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);155 this._stopLongpressTimeout();156 break;157158 case 3:159 this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);160 break;161162 default:163 this._state = GH_NOGESTURE;164 }165 }166167 _touchMove(id, x, y) {168 let touch = this._tracked.find(t => t.id === id);169170 // If this is an update for a touch we're not tracking, ignore it171 if (touch === undefined) {172 return;173 }174175 // Update the touches last position with the event coordinates176 touch.lastX = x;177 touch.lastY = y;178179 let deltaX = x - touch.firstX;180 let deltaY = y - touch.firstY;181182 // Update angle when the touch has moved183 if ((touch.firstX !== touch.lastX) ||184 (touch.firstY !== touch.lastY)) {185 touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;186 }187188 if (!this._hasDetectedGesture()) {189 // Ignore moves smaller than the minimum threshold190 if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {191 return;192 }193194 // Can't be a tap or long press as we've seen movement195 this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);196 this._stopLongpressTimeout();197198 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 }204205 // We need to figure out which of our different two touch gestures206 // this might be207 if (this._tracked.length === 2) {208209 // The other touch is the one where the id doesn't match210 let prevTouch = this._tracked.find(t => t.id !== id);211212 // How far the previous touch point has moved since start213 let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX,214 prevTouch.firstY - prevTouch.lastY);215216 // We know that the current touch moved far enough,217 // but unless both touches moved further than their218 // threshold we don't want to disqualify any gestures219 if (prevDeltaMove > GH_MOVE_THRESHOLD) {220221 // The angle difference between the direction of the touch points222 let deltaAngle = Math.abs(touch.angle - prevTouch.angle);223 deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);224225 // PINCH or TWODRAG can be eliminated depending on the angle226 if (deltaAngle > GH_ANGLE_THRESHOLD) {227 this._state &= ~GH_TWODRAG;228 } else {229 this._state &= ~GH_PINCH;230 }231232 if (this._isTwoTouchTimeoutRunning()) {233 this._stopTwoTouchTimeout();234 }235 } else if (!this._isTwoTouchTimeoutRunning()) {236 // We can't determine the gesture right now, let's237 // wait and see if more events are on their way238 this._startTwoTouchTimeout();239 }240 }241242 if (!this._hasDetectedGesture()) {243 return;244 }245246 this._pushEvent('gesturestart');247 }248249 this._pushEvent('gesturemove');250 }251252 _touchEnd(id, x, y) {253 // Check if this is an ignored touch254 if (this._ignored.indexOf(id) !== -1) {255 // Remove this touch from ignored256 this._ignored.splice(this._ignored.indexOf(id), 1);257258 // And reset the state if there are no more touches259 if ((this._ignored.length === 0) &&260 (this._tracked.length === 0)) {261 this._state = GH_INITSTATE;262 this._waitingRelease = false;263 }264 return;265 }266267 // 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 }274275 // Some gestures don't trigger until a touch is released276 if (!this._hasDetectedGesture()) {277 // Can't be a gesture that relies on movement278 this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);279 // Or something that relies on more time280 this._state &= ~GH_LONGPRESS;281 this._stopLongpressTimeout();282283 if (!this._waitingRelease) {284 this._releaseStart = Date.now();285 this._waitingRelease = true;286287 // Can't be a tap that requires more touches than we current have288 switch (this._tracked.length) {289 case 1:290 this._state &= ~(GH_TWOTAP | GH_THREETAP);291 break;292293 case 2:294 this._state &= ~(GH_ONETAP | GH_THREETAP);295 break;296 }297 }298 }299300 // 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 }306307 // 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 }311312 let touch = this._tracked.find(t => t.id === id);313 touch.active = false;314315 // 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 }325326 if (this._hasDetectedGesture()) {327 this._pushEvent('gestureend');328 }329330 // Ignore any remaining touches until they are ended331 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 = [];337338 this._state = GH_NOGESTURE;339340 // Remove this touch from ignored if it's in there341 if (this._ignored.indexOf(id) !== -1) {342 this._ignored.splice(this._ignored.indexOf(id), 1);343 }344345 // We reset the state if ignored is empty346 if ((this._ignored.length === 0)) {347 this._state = GH_INITSTATE;348 this._waitingRelease = false;349 }350 }351352 _hasDetectedGesture() {353 if (this._state === GH_NOGESTURE) {354 return false;355 }356 // Check to see if the bitmask value is a power of 2357 // (i.e. only one bit set). If it is, we have a state.358 if (this._state & (this._state - 1)) {359 return false;360 }361362 // For taps we also need to have all touches released363 // before we've fully detected the gesture364 if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {365 if (this._tracked.some(t => t.active)) {366 return false;367 }368 }369370 return true;371 }372373 _startLongpressTimeout() {374 this._stopLongpressTimeout();375 this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),376 GH_LONGPRESS_TIMEOUT);377 }378379 _stopLongpressTimeout() {380 clearTimeout(this._longpressTimeoutId);381 this._longpressTimeoutId = null;382 }383384 _longpressTimeout() {385 if (this._hasDetectedGesture()) {386 throw new Error("A longpress gesture failed, conflict with a different gesture");387 }388389 this._state = GH_LONGPRESS;390 this._pushEvent('gesturestart');391 }392393 _startTwoTouchTimeout() {394 this._stopTwoTouchTimeout();395 this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),396 GH_TWOTOUCH_TIMEOUT);397 }398399 _stopTwoTouchTimeout() {400 clearTimeout(this._twoTouchTimeoutId);401 this._twoTouchTimeoutId = null;402 }403404 _isTwoTouchTimeoutRunning() {405 return this._twoTouchTimeoutId !== null;406 }407408 _twoTouchTimeout() {409 if (this._tracked.length === 0) {410 throw new Error("A pinch or two drag gesture failed, no tracked touches");411 }412413 // How far each touch point has moved since start414 let avgM = this._getAverageMovement();415 let avgMoveH = Math.abs(avgM.x);416 let avgMoveV = Math.abs(avgM.y);417418 // The difference in the distance between where419 // the touch points started and where they are now420 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));423424 if ((avgMoveV < deltaTouchDistance) &&425 (avgMoveH < deltaTouchDistance)) {426 this._state = GH_PINCH;427 } else {428 this._state = GH_TWODRAG;429 }430431 this._pushEvent('gesturestart');432 this._pushEvent('gesturemove');433 }434435 _pushEvent(type) {436 let detail = { type: this._stateToGesture(this._state) };437438 // For most gesture events the current (average) position is the439 // most useful440 let avg = this._getPosition();441 let pos = avg.last;442443 // However we have a slight distance to detect gestures, so for the444 // first gesture event we want to use the first positions we saw445 if (type === 'gesturestart') {446 pos = avg.first;447 }448449 // For these gestures, we always want the event coordinates450 // 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 }457458 detail['clientX'] = pos.x;459 detail['clientY'] = pos.y;460461 // FIXME: other coordinates?462463 // Some gestures also have a magnitude464 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 }483484 let gev = new CustomEvent(type, { detail: detail });485 this._target.dispatchEvent(gev);486 }487488 _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 }505506 throw new Error("Unknown gesture state: " + state);507 }508509 _getPosition() {510 if (this._tracked.length === 0) {511 throw new Error("Failed to get gesture position, no tracked touches");512 }513514 let size = this._tracked.length;515 let fx = 0, fy = 0, lx = 0, ly = 0;516517 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 }523524 return { first: { x: fx / size,525 y: fy / size },526 last: { x: lx / size,527 y: ly / size } };528 }529530 _getAverageMovement() {531 if (this._tracked.length === 0) {532 throw new Error("Failed to get gesture movement, no tracked touches");533 }534535 let totalH, totalV;536 totalH = totalV = 0;537 let size = this._tracked.length;538539 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 }543544 return { x: totalH / size,545 y: totalV / size };546 }547548 _getAverageDistance() {549 if (this._tracked.length === 0) {550 throw new Error("Failed to get gesture distance, no tracked touches");551 }552553 // Distance between the first and last tracked touches554555 let first = this._tracked[0];556 let last = this._tracked[this._tracked.length - 1];557558 let fdx = Math.abs(last.firstX - first.firstX);559 let fdy = Math.abs(last.firstY - first.firstY);560561 let ldx = Math.abs(last.lastX - first.lastX);562 let ldy = Math.abs(last.lastY - first.lastY);563564 return { first: { x: fdx, y: fdy },565 last: { x: ldx, y: ldy } };566 }567}568