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