]>
Commit | Line | Data |
---|---|---|
d3796c14 JM |
1 | /* |
2 | * noVNC: HTML5 VNC client | |
1d728ace | 3 | * Copyright (C) 2012 Joel Martin |
b2f1961a | 4 | * Copyright (C) 2013 Samuel Mannehed for Cendio AB |
1d728ace | 5 | * Licensed under MPL 2.0 or any later version (see LICENSE.txt) |
d3796c14 JM |
6 | */ |
7 | ||
d6e281ba | 8 | /*jslint browser: true, white: false */ |
d3796c14 JM |
9 | /*global window, Util */ |
10 | ||
6d6f0db0 SR |
11 | import * as Log from '../util/logging.js'; |
12 | import { isTouchDevice } from '../util/browsers.js' | |
13 | import { setCapture, releaseCapture, stopEvent, getPointerEvent } from '../util/events.js'; | |
14 | import { set_defaults, make_properties } from '../util/properties.js'; | |
15 | import * as KeyboardUtil from "./util.js"; | |
16 | ||
17 | // | |
18 | // Keyboard event handler | |
19 | // | |
20 | ||
21 | const Keyboard = function (defaults) { | |
22 | this._keyDownList = []; // List of depressed keys | |
23 | // (even if they are happy) | |
9fce233d | 24 | this._pendingKey = null; // Key waiting for keypress |
6d6f0db0 | 25 | |
f7363fd2 PO |
26 | this._modifierState = KeyboardUtil.ModifierSync(); |
27 | ||
6d6f0db0 SR |
28 | set_defaults(this, defaults, { |
29 | 'target': document, | |
30 | 'focused': true | |
31 | }); | |
32 | ||
6d6f0db0 SR |
33 | // keep these here so we can refer to them later |
34 | this._eventHandlers = { | |
35 | 'keyup': this._handleKeyUp.bind(this), | |
36 | 'keydown': this._handleKeyDown.bind(this), | |
37 | 'keypress': this._handleKeyPress.bind(this), | |
38 | 'blur': this._allKeysUp.bind(this) | |
39 | }; | |
40 | }; | |
d6e281ba | 41 | |
6d6f0db0 SR |
42 | Keyboard.prototype = { |
43 | // private methods | |
d6e281ba | 44 | |
f7363fd2 PO |
45 | _sendKeyEvent: function (keysym, code, down) { |
46 | if (!this._onKeyEvent) { | |
47 | return; | |
6d6f0db0 | 48 | } |
f7363fd2 PO |
49 | |
50 | Log.Debug("onKeyEvent " + (down ? "down" : "up") + | |
51 | ", keysym: " + keysym, ", code: " + code); | |
52 | ||
53 | this._onKeyEvent(keysym, code, down); | |
54 | }, | |
55 | ||
56 | _getKeyCode: function (e) { | |
57 | var code = KeyboardUtil.getKeycode(e); | |
58 | if (code === 'Unidentified') { | |
59 | // Unstable, but we don't have anything else to go on | |
60 | // (don't use it for 'keypress' events thought since | |
61 | // WebKit sets it to the same as charCode) | |
62 | if (e.keyCode && (e.type !== 'keypress')) { | |
63 | code = 'Platform' + e.keyCode; | |
64 | } | |
65 | } | |
66 | ||
67 | return code; | |
6d6f0db0 | 68 | }, |
d6e281ba | 69 | |
6d6f0db0 SR |
70 | _handleKeyDown: function (e) { |
71 | if (!this._focused) { return; } | |
72 | ||
f7363fd2 PO |
73 | this._modifierState.keydown(e); |
74 | ||
75 | var code = this._getKeyCode(e); | |
76 | var keysym = KeyboardUtil.getKeysym(e); | |
77 | ||
78 | // If this is a legacy browser then we'll need to wait for | |
9fce233d PO |
79 | // a keypress event as well |
80 | if (!keysym) { | |
81 | this._pendingKey = code; | |
82 | return; | |
f7363fd2 PO |
83 | } |
84 | ||
9fce233d PO |
85 | this._pendingKey = null; |
86 | stopEvent(e); | |
87 | ||
f7363fd2 PO |
88 | // if a char modifier is pressed, get the keys it consists |
89 | // of (on Windows, AltGr is equivalent to Ctrl+Alt) | |
90 | var active = this._modifierState.activeCharModifier(); | |
91 | ||
92 | // If we have a char modifier down, and we're able to | |
93 | // determine a keysym reliably then (a) we know to treat | |
94 | // the modifier as a char modifier, and (b) we'll have to | |
95 | // "escape" the modifier to undo the modifier when sending | |
96 | // the char. | |
9fce233d | 97 | if (active) { |
f7363fd2 PO |
98 | var isCharModifier = false; |
99 | for (var i = 0; i < active.length; ++i) { | |
100 | if (active[i] === keysym) { | |
101 | isCharModifier = true; | |
102 | } | |
103 | } | |
104 | if (!isCharModifier) { | |
105 | var escape = this._modifierState.activeCharModifier(); | |
106 | } | |
107 | } | |
108 | ||
109 | var last; | |
110 | if (this._keyDownList.length === 0) { | |
111 | last = null; | |
6d6f0db0 | 112 | } else { |
f7363fd2 PO |
113 | last = this._keyDownList[this._keyDownList.length-1]; |
114 | } | |
115 | ||
116 | // insert a new entry if last seen key was different. | |
117 | if (!last || code === 'Unidentified' || last.code !== code) { | |
118 | last = {code: code, keysyms: {}}; | |
119 | this._keyDownList.push(last); | |
120 | } | |
121 | ||
f7363fd2 PO |
122 | // make sure last event contains this keysym (a single "logical" keyevent |
123 | // can cause multiple key events to be sent to the VNC server) | |
124 | last.keysyms[keysym] = keysym; | |
f7363fd2 PO |
125 | |
126 | // undo modifiers | |
127 | if (escape) { | |
128 | for (var i = 0; i < escape.length; ++i) { | |
129 | this._sendKeyEvent(escape[i], 'Unidentified', false); | |
130 | } | |
131 | } | |
132 | ||
133 | // send the character event | |
134 | this._sendKeyEvent(keysym, code, true); | |
135 | ||
136 | // redo modifiers | |
137 | if (escape) { | |
138 | for (i = 0; i < escape.length; ++i) { | |
139 | this._sendKeyEvent(escape[i], 'Unidentified', true); | |
140 | } | |
6d6f0db0 SR |
141 | } |
142 | }, | |
d6e281ba | 143 | |
f7363fd2 | 144 | // Legacy event for browsers without code/key |
6d6f0db0 SR |
145 | _handleKeyPress: function (e) { |
146 | if (!this._focused) { return; } | |
d6e281ba | 147 | |
f7363fd2 PO |
148 | stopEvent(e); |
149 | ||
9fce233d PO |
150 | // Are we expecting a keypress? |
151 | if (this._pendingKey === null) { | |
152 | return; | |
153 | } | |
154 | ||
f7363fd2 PO |
155 | var code = this._getKeyCode(e); |
156 | var keysym = KeyboardUtil.getKeysym(e); | |
157 | ||
9fce233d PO |
158 | // The key we were waiting for? |
159 | if ((code !== 'Unidentified') && (code != this._pendingKey)) { | |
160 | return; | |
161 | } | |
162 | ||
163 | code = this._pendingKey; | |
164 | this._pendingKey = null; | |
165 | ||
f7363fd2 PO |
166 | // if a char modifier is pressed, get the keys it consists |
167 | // of (on Windows, AltGr is equivalent to Ctrl+Alt) | |
168 | var active = this._modifierState.activeCharModifier(); | |
169 | ||
170 | // If we have a char modifier down, and we're able to | |
171 | // determine a keysym reliably then (a) we know to treat | |
172 | // the modifier as a char modifier, and (b) we'll have to | |
173 | // "escape" the modifier to undo the modifier when sending | |
174 | // the char. | |
175 | if (active && keysym) { | |
176 | var isCharModifier = false; | |
177 | for (var i = 0; i < active.length; ++i) { | |
178 | if (active[i] === keysym) { | |
179 | isCharModifier = true; | |
180 | } | |
181 | } | |
182 | if (!isCharModifier) { | |
183 | var escape = this._modifierState.activeCharModifier(); | |
184 | } | |
185 | } | |
186 | ||
187 | var last; | |
188 | if (this._keyDownList.length === 0) { | |
189 | last = null; | |
190 | } else { | |
191 | last = this._keyDownList[this._keyDownList.length-1]; | |
192 | } | |
193 | ||
194 | if (!last) { | |
195 | last = {code: code, keysyms: {}}; | |
196 | this._keyDownList.push(last); | |
197 | } | |
198 | if (!keysym) { | |
199 | console.log('keypress with no keysym:', e); | |
200 | return; | |
201 | } | |
202 | ||
f7363fd2 PO |
203 | last.keysyms[keysym] = keysym; |
204 | ||
205 | // undo modifiers | |
206 | if (escape) { | |
207 | for (var i = 0; i < escape.length; ++i) { | |
208 | this._sendKeyEvent(escape[i], 'Unidentified', false); | |
209 | } | |
210 | } | |
211 | ||
212 | // send the character event | |
213 | this._sendKeyEvent(keysym, code, true); | |
214 | ||
215 | // redo modifiers | |
216 | if (escape) { | |
217 | for (i = 0; i < escape.length; ++i) { | |
218 | this._sendKeyEvent(escape[i], 'Unidentified', true); | |
219 | } | |
6d6f0db0 SR |
220 | } |
221 | }, | |
d3796c14 | 222 | |
6d6f0db0 SR |
223 | _handleKeyUp: function (e) { |
224 | if (!this._focused) { return; } | |
c96f9003 | 225 | |
f7363fd2 PO |
226 | stopEvent(e); |
227 | ||
228 | this._modifierState.keyup(e); | |
229 | ||
230 | var code = this._getKeyCode(e); | |
231 | ||
232 | if (this._keyDownList.length === 0) { | |
233 | return; | |
234 | } | |
235 | var idx = null; | |
236 | // do we have a matching key tracked as being down? | |
237 | for (var i = 0; i !== this._keyDownList.length; ++i) { | |
238 | if (this._keyDownList[i].code === code) { | |
239 | idx = i; | |
240 | break; | |
241 | } | |
242 | } | |
243 | // if we couldn't find a match (it happens), assume it was the last key pressed | |
244 | if (idx === null) { | |
245 | idx = this._keyDownList.length - 1; | |
246 | } | |
247 | ||
248 | var item = this._keyDownList.splice(idx, 1)[0]; | |
249 | for (var key in item.keysyms) { | |
250 | this._sendKeyEvent(item.keysyms[key], code, false); | |
6d6f0db0 SR |
251 | } |
252 | }, | |
d3796c14 | 253 | |
6d6f0db0 SR |
254 | _allKeysUp: function () { |
255 | Log.Debug(">> Keyboard.allKeysUp"); | |
f7363fd2 PO |
256 | for (var i = 0; i < this._keyDownList.length; i++) { |
257 | var item = this._keyDownList[i]; | |
258 | for (var key in item.keysyms) { | |
259 | this._sendKeyEvent(item.keysyms[key], 'Unidentified', false); | |
260 | } | |
261 | }; | |
262 | this._keyDownList = []; | |
6d6f0db0 SR |
263 | Log.Debug("<< Keyboard.allKeysUp"); |
264 | }, | |
d6e281ba | 265 | |
6d6f0db0 | 266 | // Public methods |
d6e281ba | 267 | |
6d6f0db0 SR |
268 | grab: function () { |
269 | //Log.Debug(">> Keyboard.grab"); | |
270 | var c = this._target; | |
d6e281ba | 271 | |
6d6f0db0 SR |
272 | c.addEventListener('keydown', this._eventHandlers.keydown); |
273 | c.addEventListener('keyup', this._eventHandlers.keyup); | |
274 | c.addEventListener('keypress', this._eventHandlers.keypress); | |
d6e281ba | 275 | |
6d6f0db0 SR |
276 | // Release (key up) if window loses focus |
277 | window.addEventListener('blur', this._eventHandlers.blur); | |
d6e281ba | 278 | |
6d6f0db0 SR |
279 | //Log.Debug("<< Keyboard.grab"); |
280 | }, | |
d6e281ba | 281 | |
6d6f0db0 SR |
282 | ungrab: function () { |
283 | //Log.Debug(">> Keyboard.ungrab"); | |
284 | var c = this._target; | |
d6e281ba | 285 | |
6d6f0db0 SR |
286 | c.removeEventListener('keydown', this._eventHandlers.keydown); |
287 | c.removeEventListener('keyup', this._eventHandlers.keyup); | |
288 | c.removeEventListener('keypress', this._eventHandlers.keypress); | |
289 | window.removeEventListener('blur', this._eventHandlers.blur); | |
d6e281ba | 290 | |
6d6f0db0 SR |
291 | // Release (key up) all keys that are in a down state |
292 | this._allKeysUp(); | |
d3796c14 | 293 | |
6d6f0db0 SR |
294 | //Log.Debug(">> Keyboard.ungrab"); |
295 | }, | |
6d6f0db0 | 296 | }; |
d6e281ba | 297 | |
6d6f0db0 SR |
298 | make_properties(Keyboard, [ |
299 | ['target', 'wo', 'dom'], // DOM element that captures keyboard input | |
300 | ['focused', 'rw', 'bool'], // Capture and send key events | |
d6e281ba | 301 | |
d0703d1b | 302 | ['onKeyEvent', 'rw', 'func'] // Handler for key press/release |
6d6f0db0 | 303 | ]); |
d6e281ba | 304 | |
6d6f0db0 SR |
305 | const Mouse = function (defaults) { |
306 | this._mouseCaptured = false; | |
d3796c14 | 307 | |
6d6f0db0 SR |
308 | this._doubleClickTimer = null; |
309 | this._lastTouchPos = null; | |
d6e281ba | 310 | |
6d6f0db0 SR |
311 | // Configuration attributes |
312 | set_defaults(this, defaults, { | |
313 | 'target': document, | |
314 | 'focused': true, | |
315 | 'touchButton': 1 | |
316 | }); | |
d6e281ba | 317 | |
6d6f0db0 SR |
318 | this._eventHandlers = { |
319 | 'mousedown': this._handleMouseDown.bind(this), | |
320 | 'mouseup': this._handleMouseUp.bind(this), | |
321 | 'mousemove': this._handleMouseMove.bind(this), | |
322 | 'mousewheel': this._handleMouseWheel.bind(this), | |
323 | 'mousedisable': this._handleMouseDisable.bind(this) | |
d6e281ba | 324 | }; |
6d6f0db0 | 325 | }; |
d6e281ba | 326 | |
6d6f0db0 SR |
327 | Mouse.prototype = { |
328 | // private methods | |
329 | _captureMouse: function () { | |
330 | // capturing the mouse ensures we get the mouseup event | |
331 | setCapture(this._target); | |
d6e281ba | 332 | |
6d6f0db0 SR |
333 | // some browsers give us mouseup events regardless, |
334 | // so if we never captured the mouse, we can disregard the event | |
335 | this._mouseCaptured = true; | |
336 | }, | |
d6e281ba | 337 | |
6d6f0db0 SR |
338 | _releaseMouse: function () { |
339 | releaseCapture(); | |
340 | this._mouseCaptured = false; | |
341 | }, | |
d6e281ba | 342 | |
6d6f0db0 SR |
343 | _resetDoubleClickTimer: function () { |
344 | this._doubleClickTimer = null; | |
345 | }, | |
b2f1961a | 346 | |
6d6f0db0 SR |
347 | _handleMouseButton: function (e, down) { |
348 | if (!this._focused) { return; } | |
cf19ad37 | 349 | |
6d6f0db0 SR |
350 | var pos = this._getMousePosition(e); |
351 | ||
352 | var bmask; | |
353 | if (e.touches || e.changedTouches) { | |
354 | // Touch device | |
355 | ||
356 | // When two touches occur within 500 ms of each other and are | |
357 | // close enough together a double click is triggered. | |
358 | if (down == 1) { | |
359 | if (this._doubleClickTimer === null) { | |
360 | this._lastTouchPos = pos; | |
361 | } else { | |
362 | clearTimeout(this._doubleClickTimer); | |
363 | ||
364 | // When the distance between the two touches is small enough | |
365 | // force the position of the latter touch to the position of | |
366 | // the first. | |
367 | ||
368 | var xs = this._lastTouchPos.x - pos.x; | |
369 | var ys = this._lastTouchPos.y - pos.y; | |
370 | var d = Math.sqrt((xs * xs) + (ys * ys)); | |
371 | ||
372 | // The goal is to trigger on a certain physical width, the | |
373 | // devicePixelRatio brings us a bit closer but is not optimal. | |
374 | var threshold = 20 * (window.devicePixelRatio || 1); | |
375 | if (d < threshold) { | |
376 | pos = this._lastTouchPos; | |
d6e281ba | 377 | } |
a4ec2f5c | 378 | } |
6d6f0db0 | 379 | this._doubleClickTimer = setTimeout(this._resetDoubleClickTimer.bind(this), 500); |
b2f1961a | 380 | } |
6d6f0db0 SR |
381 | bmask = this._touchButton; |
382 | // If bmask is set | |
383 | } else if (e.which) { | |
384 | /* everything except IE */ | |
385 | bmask = 1 << e.button; | |
386 | } else { | |
387 | /* IE including 9 */ | |
388 | bmask = (e.button & 0x1) + // Left | |
389 | (e.button & 0x2) * 2 + // Right | |
390 | (e.button & 0x4) / 2; // Middle | |
391 | } | |
d6e281ba | 392 | |
6d6f0db0 SR |
393 | if (this._onMouseButton) { |
394 | Log.Debug("onMouseButton " + (down ? "down" : "up") + | |
395 | ", x: " + pos.x + ", y: " + pos.y + ", bmask: " + bmask); | |
396 | this._onMouseButton(pos.x, pos.y, down, bmask); | |
397 | } | |
398 | stopEvent(e); | |
399 | }, | |
d6e281ba | 400 | |
6d6f0db0 SR |
401 | _handleMouseDown: function (e) { |
402 | this._captureMouse(); | |
403 | this._handleMouseButton(e, 1); | |
404 | }, | |
d6e281ba | 405 | |
6d6f0db0 SR |
406 | _handleMouseUp: function (e) { |
407 | if (!this._mouseCaptured) { return; } | |
d6e281ba | 408 | |
6d6f0db0 SR |
409 | this._handleMouseButton(e, 0); |
410 | this._releaseMouse(); | |
411 | }, | |
d6e281ba | 412 | |
6d6f0db0 SR |
413 | _handleMouseWheel: function (e) { |
414 | if (!this._focused) { return; } | |
d6e281ba | 415 | |
6d6f0db0 | 416 | var pos = this._getMousePosition(e); |
ebb9086a | 417 | |
6d6f0db0 SR |
418 | if (this._onMouseButton) { |
419 | if (e.deltaX < 0) { | |
420 | this._onMouseButton(pos.x, pos.y, 1, 1 << 5); | |
421 | this._onMouseButton(pos.x, pos.y, 0, 1 << 5); | |
422 | } else if (e.deltaX > 0) { | |
423 | this._onMouseButton(pos.x, pos.y, 1, 1 << 6); | |
424 | this._onMouseButton(pos.x, pos.y, 0, 1 << 6); | |
d6e281ba | 425 | } |
ebb9086a | 426 | |
6d6f0db0 SR |
427 | if (e.deltaY < 0) { |
428 | this._onMouseButton(pos.x, pos.y, 1, 1 << 3); | |
429 | this._onMouseButton(pos.x, pos.y, 0, 1 << 3); | |
430 | } else if (e.deltaY > 0) { | |
431 | this._onMouseButton(pos.x, pos.y, 1, 1 << 4); | |
432 | this._onMouseButton(pos.x, pos.y, 0, 1 << 4); | |
d6e281ba | 433 | } |
6d6f0db0 | 434 | } |
d6e281ba | 435 | |
6d6f0db0 SR |
436 | stopEvent(e); |
437 | }, | |
af1b2ae1 | 438 | |
6d6f0db0 SR |
439 | _handleMouseMove: function (e) { |
440 | if (! this._focused) { return; } | |
d6e281ba | 441 | |
6d6f0db0 SR |
442 | var pos = this._getMousePosition(e); |
443 | if (this._onMouseMove) { | |
444 | this._onMouseMove(pos.x, pos.y); | |
445 | } | |
446 | stopEvent(e); | |
447 | }, | |
448 | ||
449 | _handleMouseDisable: function (e) { | |
450 | if (!this._focused) { return; } | |
451 | ||
452 | /* | |
453 | * Stop propagation if inside canvas area | |
454 | * Note: This is only needed for the 'click' event as it fails | |
455 | * to fire properly for the target element so we have | |
456 | * to listen on the document element instead. | |
457 | */ | |
458 | if (e.target == this._target) { | |
459 | stopEvent(e); | |
460 | } | |
461 | }, | |
462 | ||
463 | // Return coordinates relative to target | |
464 | _getMousePosition: function(e) { | |
465 | e = getPointerEvent(e); | |
466 | var bounds = this._target.getBoundingClientRect(); | |
467 | var x, y; | |
468 | // Clip to target bounds | |
469 | if (e.clientX < bounds.left) { | |
470 | x = 0; | |
471 | } else if (e.clientX >= bounds.right) { | |
472 | x = bounds.width - 1; | |
473 | } else { | |
474 | x = e.clientX - bounds.left; | |
475 | } | |
476 | if (e.clientY < bounds.top) { | |
477 | y = 0; | |
478 | } else if (e.clientY >= bounds.bottom) { | |
479 | y = bounds.height - 1; | |
480 | } else { | |
481 | y = e.clientY - bounds.top; | |
482 | } | |
483 | return {x:x, y:y}; | |
484 | }, | |
485 | ||
486 | // Public methods | |
487 | grab: function () { | |
488 | var c = this._target; | |
489 | ||
490 | if (isTouchDevice) { | |
491 | c.addEventListener('touchstart', this._eventHandlers.mousedown); | |
492 | window.addEventListener('touchend', this._eventHandlers.mouseup); | |
493 | c.addEventListener('touchend', this._eventHandlers.mouseup); | |
494 | c.addEventListener('touchmove', this._eventHandlers.mousemove); | |
495 | } | |
496 | c.addEventListener('mousedown', this._eventHandlers.mousedown); | |
497 | window.addEventListener('mouseup', this._eventHandlers.mouseup); | |
498 | c.addEventListener('mouseup', this._eventHandlers.mouseup); | |
499 | c.addEventListener('mousemove', this._eventHandlers.mousemove); | |
500 | c.addEventListener('wheel', this._eventHandlers.mousewheel); | |
501 | ||
502 | /* Prevent middle-click pasting (see above for why we bind to document) */ | |
503 | document.addEventListener('click', this._eventHandlers.mousedisable); | |
504 | ||
505 | /* preventDefault() on mousedown doesn't stop this event for some | |
506 | reason so we have to explicitly block it */ | |
507 | c.addEventListener('contextmenu', this._eventHandlers.mousedisable); | |
508 | }, | |
509 | ||
510 | ungrab: function () { | |
511 | var c = this._target; | |
512 | ||
513 | if (isTouchDevice) { | |
514 | c.removeEventListener('touchstart', this._eventHandlers.mousedown); | |
515 | window.removeEventListener('touchend', this._eventHandlers.mouseup); | |
516 | c.removeEventListener('touchend', this._eventHandlers.mouseup); | |
517 | c.removeEventListener('touchmove', this._eventHandlers.mousemove); | |
518 | } | |
519 | c.removeEventListener('mousedown', this._eventHandlers.mousedown); | |
520 | window.removeEventListener('mouseup', this._eventHandlers.mouseup); | |
521 | c.removeEventListener('mouseup', this._eventHandlers.mouseup); | |
522 | c.removeEventListener('mousemove', this._eventHandlers.mousemove); | |
523 | c.removeEventListener('wheel', this._eventHandlers.mousewheel); | |
d6e281ba | 524 | |
6d6f0db0 | 525 | document.removeEventListener('click', this._eventHandlers.mousedisable); |
d6e281ba | 526 | |
6d6f0db0 SR |
527 | c.removeEventListener('contextmenu', this._eventHandlers.mousedisable); |
528 | } | |
529 | }; | |
530 | ||
531 | make_properties(Mouse, [ | |
532 | ['target', 'ro', 'dom'], // DOM element that captures mouse input | |
6d6f0db0 | 533 | ['focused', 'rw', 'bool'], // Capture and send mouse clicks/movement |
d6e281ba | 534 | |
6d6f0db0 SR |
535 | ['onMouseButton', 'rw', 'func'], // Handler for mouse button click/release |
536 | ['onMouseMove', 'rw', 'func'], // Handler for mouse movement | |
537 | ['touchButton', 'rw', 'int'] // Button mask (1, 2, 4) for touch devices (0 means ignore clicks) | |
538 | ]); | |
d6e281ba | 539 | |
6d6f0db0 | 540 | export { Keyboard, Mouse }; |