| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- /*
- * 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 } };
- }
- }
|