gesturehandler.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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. const GH_NOGESTURE = 0;
  10. const GH_ONETAP = 1;
  11. const GH_TWOTAP = 2;
  12. const GH_THREETAP = 4;
  13. const GH_DRAG = 8;
  14. const GH_LONGPRESS = 16;
  15. const GH_TWODRAG = 32;
  16. const GH_PINCH = 64;
  17. const GH_INITSTATE = 127;
  18. const GH_MOVE_THRESHOLD = 50;
  19. const GH_ANGLE_THRESHOLD = 90; // Degrees
  20. // Timeout when waiting for gestures (ms)
  21. const GH_MULTITOUCH_TIMEOUT = 250;
  22. // Maximum time between press and release for a tap (ms)
  23. const GH_TAP_TIMEOUT = 1000;
  24. // Timeout when waiting for longpress (ms)
  25. const GH_LONGPRESS_TIMEOUT = 1000;
  26. // Timeout when waiting to decide between PINCH and TWODRAG (ms)
  27. const GH_TWOTOUCH_TIMEOUT = 50;
  28. export default class GestureHandler {
  29. constructor() {
  30. this._target = null;
  31. this._state = GH_INITSTATE;
  32. this._tracked = [];
  33. this._ignored = [];
  34. this._waitingRelease = false;
  35. this._releaseStart = 0.0;
  36. this._longpressTimeoutId = null;
  37. this._twoTouchTimeoutId = null;
  38. this._boundEventHandler = this._eventHandler.bind(this);
  39. }
  40. attach(target) {
  41. this.detach();
  42. this._target = target;
  43. this._target.addEventListener('touchstart',
  44. this._boundEventHandler);
  45. this._target.addEventListener('touchmove',
  46. this._boundEventHandler);
  47. this._target.addEventListener('touchend',
  48. this._boundEventHandler);
  49. this._target.addEventListener('touchcancel',
  50. this._boundEventHandler);
  51. }
  52. detach() {
  53. if (!this._target) {
  54. return;
  55. }
  56. this._stopLongpressTimeout();
  57. this._stopTwoTouchTimeout();
  58. this._target.removeEventListener('touchstart',
  59. this._boundEventHandler);
  60. this._target.removeEventListener('touchmove',
  61. this._boundEventHandler);
  62. this._target.removeEventListener('touchend',
  63. this._boundEventHandler);
  64. this._target.removeEventListener('touchcancel',
  65. this._boundEventHandler);
  66. this._target = null;
  67. }
  68. _eventHandler(e) {
  69. let fn;
  70. e.stopPropagation();
  71. e.preventDefault();
  72. switch (e.type) {
  73. case 'touchstart':
  74. fn = this._touchStart;
  75. break;
  76. case 'touchmove':
  77. fn = this._touchMove;
  78. break;
  79. case 'touchend':
  80. case 'touchcancel':
  81. fn = this._touchEnd;
  82. break;
  83. }
  84. for (let i = 0; i < e.changedTouches.length; i++) {
  85. let touch = e.changedTouches[i];
  86. fn.call(this, touch.identifier, touch.clientX, touch.clientY);
  87. }
  88. }
  89. _touchStart(id, x, y) {
  90. // Ignore any new touches if there is already an active gesture,
  91. // or we're in a cleanup state
  92. if (this._hasDetectedGesture() || (this._state === GH_NOGESTURE)) {
  93. this._ignored.push(id);
  94. return;
  95. }
  96. // Did it take too long between touches that we should no longer
  97. // consider this a single gesture?
  98. if ((this._tracked.length > 0) &&
  99. ((Date.now() - this._tracked[0].started) > GH_MULTITOUCH_TIMEOUT)) {
  100. this._state = GH_NOGESTURE;
  101. this._ignored.push(id);
  102. return;
  103. }
  104. // If we're waiting for fingers to release then we should no longer
  105. // recognize new touches
  106. if (this._waitingRelease) {
  107. this._state = GH_NOGESTURE;
  108. this._ignored.push(id);
  109. return;
  110. }
  111. this._tracked.push({
  112. id: id,
  113. started: Date.now(),
  114. active: true,
  115. firstX: x,
  116. firstY: y,
  117. lastX: x,
  118. lastY: y,
  119. angle: 0
  120. });
  121. switch (this._tracked.length) {
  122. case 1:
  123. this._startLongpressTimeout();
  124. break;
  125. case 2:
  126. this._state &= ~(GH_ONETAP | GH_DRAG | GH_LONGPRESS);
  127. this._stopLongpressTimeout();
  128. break;
  129. case 3:
  130. this._state &= ~(GH_TWOTAP | GH_TWODRAG | GH_PINCH);
  131. break;
  132. default:
  133. this._state = GH_NOGESTURE;
  134. }
  135. }
  136. _touchMove(id, x, y) {
  137. let touch = this._tracked.find(t => t.id === id);
  138. // If this is an update for a touch we're not tracking, ignore it
  139. if (touch === undefined) {
  140. return;
  141. }
  142. // Update the touches last position with the event coordinates
  143. touch.lastX = x;
  144. touch.lastY = y;
  145. let deltaX = x - touch.firstX;
  146. let deltaY = y - touch.firstY;
  147. // Update angle when the touch has moved
  148. if ((touch.firstX !== touch.lastX) ||
  149. (touch.firstY !== touch.lastY)) {
  150. touch.angle = Math.atan2(deltaY, deltaX) * 180 / Math.PI;
  151. }
  152. if (!this._hasDetectedGesture()) {
  153. // Ignore moves smaller than the minimum threshold
  154. if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) {
  155. return;
  156. }
  157. // Can't be a tap or long press as we've seen movement
  158. this._state &= ~(GH_ONETAP | GH_TWOTAP | GH_THREETAP | GH_LONGPRESS);
  159. this._stopLongpressTimeout();
  160. if (this._tracked.length !== 1) {
  161. this._state &= ~(GH_DRAG);
  162. }
  163. if (this._tracked.length !== 2) {
  164. this._state &= ~(GH_TWODRAG | GH_PINCH);
  165. }
  166. // We need to figure out which of our different two touch gestures
  167. // this might be
  168. if (this._tracked.length === 2) {
  169. // The other touch is the one where the id doesn't match
  170. let prevTouch = this._tracked.find(t => t.id !== id);
  171. // How far the previous touch point has moved since start
  172. let prevDeltaMove = Math.hypot(prevTouch.firstX - prevTouch.lastX,
  173. prevTouch.firstY - prevTouch.lastY);
  174. // We know that the current touch moved far enough,
  175. // but unless both touches moved further than their
  176. // threshold we don't want to disqualify any gestures
  177. if (prevDeltaMove > GH_MOVE_THRESHOLD) {
  178. // The angle difference between the direction of the touch points
  179. let deltaAngle = Math.abs(touch.angle - prevTouch.angle);
  180. deltaAngle = Math.abs(((deltaAngle + 180) % 360) - 180);
  181. // PINCH or TWODRAG can be eliminated depending on the angle
  182. if (deltaAngle > GH_ANGLE_THRESHOLD) {
  183. this._state &= ~GH_TWODRAG;
  184. } else {
  185. this._state &= ~GH_PINCH;
  186. }
  187. if (this._isTwoTouchTimeoutRunning()) {
  188. this._stopTwoTouchTimeout();
  189. }
  190. } else if (!this._isTwoTouchTimeoutRunning()) {
  191. // We can't determine the gesture right now, let's
  192. // wait and see if more events are on their way
  193. this._startTwoTouchTimeout();
  194. }
  195. }
  196. if (!this._hasDetectedGesture()) {
  197. return;
  198. }
  199. this._pushEvent('gesturestart');
  200. }
  201. this._pushEvent('gesturemove');
  202. }
  203. _touchEnd(id, x, y) {
  204. // Check if this is an ignored touch
  205. if (this._ignored.indexOf(id) !== -1) {
  206. // Remove this touch from ignored
  207. this._ignored.splice(this._ignored.indexOf(id), 1);
  208. // And reset the state if there are no more touches
  209. if ((this._ignored.length === 0) &&
  210. (this._tracked.length === 0)) {
  211. this._state = GH_INITSTATE;
  212. this._waitingRelease = false;
  213. }
  214. return;
  215. }
  216. // We got a touchend before the timer triggered,
  217. // this cannot result in a gesture anymore.
  218. if (!this._hasDetectedGesture() &&
  219. this._isTwoTouchTimeoutRunning()) {
  220. this._stopTwoTouchTimeout();
  221. this._state = GH_NOGESTURE;
  222. }
  223. // Some gestures don't trigger until a touch is released
  224. if (!this._hasDetectedGesture()) {
  225. // Can't be a gesture that relies on movement
  226. this._state &= ~(GH_DRAG | GH_TWODRAG | GH_PINCH);
  227. // Or something that relies on more time
  228. this._state &= ~GH_LONGPRESS;
  229. this._stopLongpressTimeout();
  230. if (!this._waitingRelease) {
  231. this._releaseStart = Date.now();
  232. this._waitingRelease = true;
  233. // Can't be a tap that requires more touches than we current have
  234. switch (this._tracked.length) {
  235. case 1:
  236. this._state &= ~(GH_TWOTAP | GH_THREETAP);
  237. break;
  238. case 2:
  239. this._state &= ~(GH_ONETAP | GH_THREETAP);
  240. break;
  241. }
  242. }
  243. }
  244. // Waiting for all touches to release? (i.e. some tap)
  245. if (this._waitingRelease) {
  246. // Were all touches released at roughly the same time?
  247. if ((Date.now() - this._releaseStart) > GH_MULTITOUCH_TIMEOUT) {
  248. this._state = GH_NOGESTURE;
  249. }
  250. // Did too long time pass between press and release?
  251. if (this._tracked.some(t => (Date.now() - t.started) > GH_TAP_TIMEOUT)) {
  252. this._state = GH_NOGESTURE;
  253. }
  254. let touch = this._tracked.find(t => t.id === id);
  255. touch.active = false;
  256. // Are we still waiting for more releases?
  257. if (this._hasDetectedGesture()) {
  258. this._pushEvent('gesturestart');
  259. } else {
  260. // Have we reached a dead end?
  261. if (this._state !== GH_NOGESTURE) {
  262. return;
  263. }
  264. }
  265. }
  266. if (this._hasDetectedGesture()) {
  267. this._pushEvent('gestureend');
  268. }
  269. // Ignore any remaining touches until they are ended
  270. for (let i = 0; i < this._tracked.length; i++) {
  271. if (this._tracked[i].active) {
  272. this._ignored.push(this._tracked[i].id);
  273. }
  274. }
  275. this._tracked = [];
  276. this._state = GH_NOGESTURE;
  277. // Remove this touch from ignored if it's in there
  278. if (this._ignored.indexOf(id) !== -1) {
  279. this._ignored.splice(this._ignored.indexOf(id), 1);
  280. }
  281. // We reset the state if ignored is empty
  282. if ((this._ignored.length === 0)) {
  283. this._state = GH_INITSTATE;
  284. this._waitingRelease = false;
  285. }
  286. }
  287. _hasDetectedGesture() {
  288. if (this._state === GH_NOGESTURE) {
  289. return false;
  290. }
  291. // Check to see if the bitmask value is a power of 2
  292. // (i.e. only one bit set). If it is, we have a state.
  293. if (this._state & (this._state - 1)) {
  294. return false;
  295. }
  296. // For taps we also need to have all touches released
  297. // before we've fully detected the gesture
  298. if (this._state & (GH_ONETAP | GH_TWOTAP | GH_THREETAP)) {
  299. if (this._tracked.some(t => t.active)) {
  300. return false;
  301. }
  302. }
  303. return true;
  304. }
  305. _startLongpressTimeout() {
  306. this._stopLongpressTimeout();
  307. this._longpressTimeoutId = setTimeout(() => this._longpressTimeout(),
  308. GH_LONGPRESS_TIMEOUT);
  309. }
  310. _stopLongpressTimeout() {
  311. clearTimeout(this._longpressTimeoutId);
  312. this._longpressTimeoutId = null;
  313. }
  314. _longpressTimeout() {
  315. if (this._hasDetectedGesture()) {
  316. throw new Error("A longpress gesture failed, conflict with a different gesture");
  317. }
  318. this._state = GH_LONGPRESS;
  319. this._pushEvent('gesturestart');
  320. }
  321. _startTwoTouchTimeout() {
  322. this._stopTwoTouchTimeout();
  323. this._twoTouchTimeoutId = setTimeout(() => this._twoTouchTimeout(),
  324. GH_TWOTOUCH_TIMEOUT);
  325. }
  326. _stopTwoTouchTimeout() {
  327. clearTimeout(this._twoTouchTimeoutId);
  328. this._twoTouchTimeoutId = null;
  329. }
  330. _isTwoTouchTimeoutRunning() {
  331. return this._twoTouchTimeoutId !== null;
  332. }
  333. _twoTouchTimeout() {
  334. if (this._tracked.length === 0) {
  335. throw new Error("A pinch or two drag gesture failed, no tracked touches");
  336. }
  337. // How far each touch point has moved since start
  338. let avgM = this._getAverageMovement();
  339. let avgMoveH = Math.abs(avgM.x);
  340. let avgMoveV = Math.abs(avgM.y);
  341. // The difference in the distance between where
  342. // the touch points started and where they are now
  343. let avgD = this._getAverageDistance();
  344. let deltaTouchDistance = Math.abs(Math.hypot(avgD.first.x, avgD.first.y) -
  345. Math.hypot(avgD.last.x, avgD.last.y));
  346. if ((avgMoveV < deltaTouchDistance) &&
  347. (avgMoveH < deltaTouchDistance)) {
  348. this._state = GH_PINCH;
  349. } else {
  350. this._state = GH_TWODRAG;
  351. }
  352. this._pushEvent('gesturestart');
  353. this._pushEvent('gesturemove');
  354. }
  355. _pushEvent(type) {
  356. let detail = { type: this._stateToGesture(this._state) };
  357. // For most gesture events the current (average) position is the
  358. // most useful
  359. let avg = this._getPosition();
  360. let pos = avg.last;
  361. // However we have a slight distance to detect gestures, so for the
  362. // first gesture event we want to use the first positions we saw
  363. if (type === 'gesturestart') {
  364. pos = avg.first;
  365. }
  366. // For these gestures, we always want the event coordinates
  367. // to be where the gesture began, not the current touch location.
  368. switch (this._state) {
  369. case GH_TWODRAG:
  370. case GH_PINCH:
  371. pos = avg.first;
  372. break;
  373. }
  374. detail['clientX'] = pos.x;
  375. detail['clientY'] = pos.y;
  376. // FIXME: other coordinates?
  377. // Some gestures also have a magnitude
  378. if (this._state === GH_PINCH) {
  379. let distance = this._getAverageDistance();
  380. if (type === 'gesturestart') {
  381. detail['magnitudeX'] = distance.first.x;
  382. detail['magnitudeY'] = distance.first.y;
  383. } else {
  384. detail['magnitudeX'] = distance.last.x;
  385. detail['magnitudeY'] = distance.last.y;
  386. }
  387. } else if (this._state === GH_TWODRAG) {
  388. if (type === 'gesturestart') {
  389. detail['magnitudeX'] = 0.0;
  390. detail['magnitudeY'] = 0.0;
  391. } else {
  392. let movement = this._getAverageMovement();
  393. detail['magnitudeX'] = movement.x;
  394. detail['magnitudeY'] = movement.y;
  395. }
  396. }
  397. let gev = new CustomEvent(type, { detail: detail });
  398. this._target.dispatchEvent(gev);
  399. }
  400. _stateToGesture(state) {
  401. switch (state) {
  402. case GH_ONETAP:
  403. return 'onetap';
  404. case GH_TWOTAP:
  405. return 'twotap';
  406. case GH_THREETAP:
  407. return 'threetap';
  408. case GH_DRAG:
  409. return 'drag';
  410. case GH_LONGPRESS:
  411. return 'longpress';
  412. case GH_TWODRAG:
  413. return 'twodrag';
  414. case GH_PINCH:
  415. return 'pinch';
  416. }
  417. throw new Error("Unknown gesture state: " + state);
  418. }
  419. _getPosition() {
  420. if (this._tracked.length === 0) {
  421. throw new Error("Failed to get gesture position, no tracked touches");
  422. }
  423. let size = this._tracked.length;
  424. let fx = 0, fy = 0, lx = 0, ly = 0;
  425. for (let i = 0; i < this._tracked.length; i++) {
  426. fx += this._tracked[i].firstX;
  427. fy += this._tracked[i].firstY;
  428. lx += this._tracked[i].lastX;
  429. ly += this._tracked[i].lastY;
  430. }
  431. return { first: { x: fx / size,
  432. y: fy / size },
  433. last: { x: lx / size,
  434. y: ly / size } };
  435. }
  436. _getAverageMovement() {
  437. if (this._tracked.length === 0) {
  438. throw new Error("Failed to get gesture movement, no tracked touches");
  439. }
  440. let totalH, totalV;
  441. totalH = totalV = 0;
  442. let size = this._tracked.length;
  443. for (let i = 0; i < this._tracked.length; i++) {
  444. totalH += this._tracked[i].lastX - this._tracked[i].firstX;
  445. totalV += this._tracked[i].lastY - this._tracked[i].firstY;
  446. }
  447. return { x: totalH / size,
  448. y: totalV / size };
  449. }
  450. _getAverageDistance() {
  451. if (this._tracked.length === 0) {
  452. throw new Error("Failed to get gesture distance, no tracked touches");
  453. }
  454. // Distance between the first and last tracked touches
  455. let first = this._tracked[0];
  456. let last = this._tracked[this._tracked.length - 1];
  457. let fdx = Math.abs(last.firstX - first.firstX);
  458. let fdy = Math.abs(last.firstY - first.firstY);
  459. let ldx = Math.abs(last.lastX - first.lastX);
  460. let ldy = Math.abs(last.lastY - first.lastY);
  461. return { first: { x: fdx, y: fdy },
  462. last: { x: ldx, y: ldy } };
  463. }
  464. }