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