mouse.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. /*
  2. * noVNC: HTML5 VNC client
  3. * Copyright (C) 2018 The noVNC Authors
  4. * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
  5. */
  6. import * as Log from '../util/logging.js';
  7. import { isTouchDevice } from '../util/browser.js';
  8. import { setCapture, stopEvent, getPointerEvent } from '../util/events.js';
  9. const WHEEL_STEP = 10; // Delta threshold for a mouse wheel step
  10. const WHEEL_STEP_TIMEOUT = 50; // ms
  11. const WHEEL_LINE_HEIGHT = 19;
  12. export default class Mouse {
  13. constructor(target) {
  14. this._target = target || document;
  15. this._doubleClickTimer = null;
  16. this._lastTouchPos = null;
  17. this._pos = null;
  18. this._wheelStepXTimer = null;
  19. this._wheelStepYTimer = null;
  20. this._accumulatedWheelDeltaX = 0;
  21. this._accumulatedWheelDeltaY = 0;
  22. this._eventHandlers = {
  23. 'mousedown': this._handleMouseDown.bind(this),
  24. 'mouseup': this._handleMouseUp.bind(this),
  25. 'mousemove': this._handleMouseMove.bind(this),
  26. 'mousewheel': this._handleMouseWheel.bind(this),
  27. 'mousedisable': this._handleMouseDisable.bind(this)
  28. };
  29. // ===== PROPERTIES =====
  30. this.touchButton = 1; // Button mask (1, 2, 4) for touch devices (0 means ignore clicks)
  31. // ===== EVENT HANDLERS =====
  32. this.onmousebutton = () => {}; // Handler for mouse button click/release
  33. this.onmousemove = () => {}; // Handler for mouse movement
  34. }
  35. // ===== PRIVATE METHODS =====
  36. _resetDoubleClickTimer() {
  37. this._doubleClickTimer = null;
  38. }
  39. _handleMouseButton(e, down) {
  40. this._updateMousePosition(e);
  41. let pos = this._pos;
  42. let bmask;
  43. if (e.touches || e.changedTouches) {
  44. // Touch device
  45. // When two touches occur within 500 ms of each other and are
  46. // close enough together a double click is triggered.
  47. if (down == 1) {
  48. if (this._doubleClickTimer === null) {
  49. this._lastTouchPos = pos;
  50. } else {
  51. clearTimeout(this._doubleClickTimer);
  52. // When the distance between the two touches is small enough
  53. // force the position of the latter touch to the position of
  54. // the first.
  55. const xs = this._lastTouchPos.x - pos.x;
  56. const ys = this._lastTouchPos.y - pos.y;
  57. const d = Math.sqrt((xs * xs) + (ys * ys));
  58. // The goal is to trigger on a certain physical width, the
  59. // devicePixelRatio brings us a bit closer but is not optimal.
  60. const threshold = 20 * (window.devicePixelRatio || 1);
  61. if (d < threshold) {
  62. pos = this._lastTouchPos;
  63. }
  64. }
  65. this._doubleClickTimer = setTimeout(this._resetDoubleClickTimer.bind(this), 500);
  66. }
  67. bmask = this.touchButton;
  68. // If bmask is set
  69. } else if (e.which) {
  70. /* everything except IE */
  71. bmask = 1 << e.button;
  72. } else {
  73. /* IE including 9 */
  74. bmask = (e.button & 0x1) + // Left
  75. (e.button & 0x2) * 2 + // Right
  76. (e.button & 0x4) / 2; // Middle
  77. }
  78. Log.Debug("onmousebutton " + (down ? "down" : "up") +
  79. ", x: " + pos.x + ", y: " + pos.y + ", bmask: " + bmask);
  80. this.onmousebutton(pos.x, pos.y, down, bmask);
  81. stopEvent(e);
  82. }
  83. _handleMouseDown(e) {
  84. // Touch events have implicit capture
  85. if (e.type === "mousedown") {
  86. setCapture(this._target);
  87. }
  88. this._handleMouseButton(e, 1);
  89. }
  90. _handleMouseUp(e) {
  91. this._handleMouseButton(e, 0);
  92. }
  93. // Mouse wheel events are sent in steps over VNC. This means that the VNC
  94. // protocol can't handle a wheel event with specific distance or speed.
  95. // Therefor, if we get a lot of small mouse wheel events we combine them.
  96. _generateWheelStepX() {
  97. if (this._accumulatedWheelDeltaX < 0) {
  98. this.onmousebutton(this._pos.x, this._pos.y, 1, 1 << 5);
  99. this.onmousebutton(this._pos.x, this._pos.y, 0, 1 << 5);
  100. } else if (this._accumulatedWheelDeltaX > 0) {
  101. this.onmousebutton(this._pos.x, this._pos.y, 1, 1 << 6);
  102. this.onmousebutton(this._pos.x, this._pos.y, 0, 1 << 6);
  103. }
  104. this._accumulatedWheelDeltaX = 0;
  105. }
  106. _generateWheelStepY() {
  107. if (this._accumulatedWheelDeltaY < 0) {
  108. this.onmousebutton(this._pos.x, this._pos.y, 1, 1 << 3);
  109. this.onmousebutton(this._pos.x, this._pos.y, 0, 1 << 3);
  110. } else if (this._accumulatedWheelDeltaY > 0) {
  111. this.onmousebutton(this._pos.x, this._pos.y, 1, 1 << 4);
  112. this.onmousebutton(this._pos.x, this._pos.y, 0, 1 << 4);
  113. }
  114. this._accumulatedWheelDeltaY = 0;
  115. }
  116. _resetWheelStepTimers() {
  117. window.clearTimeout(this._wheelStepXTimer);
  118. window.clearTimeout(this._wheelStepYTimer);
  119. this._wheelStepXTimer = null;
  120. this._wheelStepYTimer = null;
  121. }
  122. _handleMouseWheel(e) {
  123. this._resetWheelStepTimers();
  124. this._updateMousePosition(e);
  125. let dX = e.deltaX;
  126. let dY = e.deltaY;
  127. // Pixel units unless it's non-zero.
  128. // Note that if deltamode is line or page won't matter since we aren't
  129. // sending the mouse wheel delta to the server anyway.
  130. // The difference between pixel and line can be important however since
  131. // we have a threshold that can be smaller than the line height.
  132. if (e.deltaMode !== 0) {
  133. dX *= WHEEL_LINE_HEIGHT;
  134. dY *= WHEEL_LINE_HEIGHT;
  135. }
  136. this._accumulatedWheelDeltaX += dX;
  137. this._accumulatedWheelDeltaY += dY;
  138. // Generate a mouse wheel step event when the accumulated delta
  139. // for one of the axes is large enough.
  140. // Small delta events that do not pass the threshold get sent
  141. // after a timeout.
  142. if (Math.abs(this._accumulatedWheelDeltaX) > WHEEL_STEP) {
  143. this._generateWheelStepX();
  144. } else {
  145. this._wheelStepXTimer =
  146. window.setTimeout(this._generateWheelStepX.bind(this),
  147. WHEEL_STEP_TIMEOUT);
  148. }
  149. if (Math.abs(this._accumulatedWheelDeltaY) > WHEEL_STEP) {
  150. this._generateWheelStepY();
  151. } else {
  152. this._wheelStepYTimer =
  153. window.setTimeout(this._generateWheelStepY.bind(this),
  154. WHEEL_STEP_TIMEOUT);
  155. }
  156. stopEvent(e);
  157. }
  158. _handleMouseMove(e) {
  159. this._updateMousePosition(e);
  160. this.onmousemove(this._pos.x, this._pos.y);
  161. stopEvent(e);
  162. }
  163. _handleMouseDisable(e) {
  164. /*
  165. * Stop propagation if inside canvas area
  166. * Note: This is only needed for the 'click' event as it fails
  167. * to fire properly for the target element so we have
  168. * to listen on the document element instead.
  169. */
  170. if (e.target == this._target) {
  171. stopEvent(e);
  172. }
  173. }
  174. // Update coordinates relative to target
  175. _updateMousePosition(e) {
  176. e = getPointerEvent(e);
  177. const bounds = this._target.getBoundingClientRect();
  178. let x;
  179. let y;
  180. // Clip to target bounds
  181. if (e.clientX < bounds.left) {
  182. x = 0;
  183. } else if (e.clientX >= bounds.right) {
  184. x = bounds.width - 1;
  185. } else {
  186. x = e.clientX - bounds.left;
  187. }
  188. if (e.clientY < bounds.top) {
  189. y = 0;
  190. } else if (e.clientY >= bounds.bottom) {
  191. y = bounds.height - 1;
  192. } else {
  193. y = e.clientY - bounds.top;
  194. }
  195. this._pos = {x: x, y: y};
  196. }
  197. // ===== PUBLIC METHODS =====
  198. grab() {
  199. if (isTouchDevice) {
  200. this._target.addEventListener('touchstart', this._eventHandlers.mousedown);
  201. this._target.addEventListener('touchend', this._eventHandlers.mouseup);
  202. this._target.addEventListener('touchmove', this._eventHandlers.mousemove);
  203. }
  204. this._target.addEventListener('mousedown', this._eventHandlers.mousedown);
  205. this._target.addEventListener('mouseup', this._eventHandlers.mouseup);
  206. this._target.addEventListener('mousemove', this._eventHandlers.mousemove);
  207. this._target.addEventListener('wheel', this._eventHandlers.mousewheel);
  208. /* Prevent middle-click pasting (see above for why we bind to document) */
  209. document.addEventListener('click', this._eventHandlers.mousedisable);
  210. /* preventDefault() on mousedown doesn't stop this event for some
  211. reason so we have to explicitly block it */
  212. this._target.addEventListener('contextmenu', this._eventHandlers.mousedisable);
  213. }
  214. ungrab() {
  215. this._resetWheelStepTimers();
  216. if (isTouchDevice) {
  217. this._target.removeEventListener('touchstart', this._eventHandlers.mousedown);
  218. this._target.removeEventListener('touchend', this._eventHandlers.mouseup);
  219. this._target.removeEventListener('touchmove', this._eventHandlers.mousemove);
  220. }
  221. this._target.removeEventListener('mousedown', this._eventHandlers.mousedown);
  222. this._target.removeEventListener('mouseup', this._eventHandlers.mouseup);
  223. this._target.removeEventListener('mousemove', this._eventHandlers.mousemove);
  224. this._target.removeEventListener('wheel', this._eventHandlers.mousewheel);
  225. document.removeEventListener('click', this._eventHandlers.mousedisable);
  226. this._target.removeEventListener('contextmenu', this._eventHandlers.mousedisable);
  227. }
  228. }