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