This commit is contained in:
lhchavez 2022-08-31 15:42:01 +03:00 committed by GitHub
commit c0dc4d0bce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 382 additions and 17 deletions

78
app/images/pointer.svg Normal file
View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="pointer.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/keyboard.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#717171"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="6.9841519"
inkscape:cy="18.584699"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="false"
inkscape:window-width="2560"
inkscape:window-height="1403"
inkscape:window-x="2560"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:object-nodes="true"
inkscape:snap-midpoints="true"
inkscape:snap-smooth-nodes="true"
inkscape:document-rotation="0">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 6.3910823,1030.3965 v 17.497 l 3.5465954,-2.6671 1.5862113,4.1015 4.336661,-1.5752 -1.59331,-4.2624 4.341678,-0.3562 z"
id="path879" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -232,6 +232,10 @@ const UI = {
document.getElementById("noVNC_view_drag_button")
.addEventListener('click', UI.toggleViewDrag);
document
.getElementById("noVNC_pointer_lock_button")
.addEventListener("click", UI.requestPointerLock);
document.getElementById("noVNC_control_bar_handle")
.addEventListener('mousedown', UI.controlbarHandleMouseDown);
document.getElementById("noVNC_control_bar_handle")
@ -453,6 +457,7 @@ const UI = {
UI.updatePowerButton();
UI.keepControlbar();
}
UI.updatePointerLockButton();
// State change closes dialogs as they may not be relevant
// anymore
@ -1051,6 +1056,7 @@ const UI = {
UI.rfb.addEventListener("clipboard", UI.clipboardReceive);
UI.rfb.addEventListener("bell", UI.bell);
UI.rfb.addEventListener("desktopname", UI.updateDesktopName);
UI.rfb.addEventListener("inputlock", UI.inputLockChanged);
UI.rfb.clipViewport = UI.getSetting('view_clip');
UI.rfb.scaleViewport = UI.getSetting('resize') === 'scale';
UI.rfb.resizeSession = UI.getSetting('resize') === 'remote';
@ -1293,6 +1299,7 @@ const UI = {
document.getElementById('noVNC_fullscreen_button')
.classList.remove("noVNC_selected");
}
UI.updatePointerLockButton();
},
/* ------^-------
@ -1345,6 +1352,38 @@ const UI = {
/* ------^-------
* /VIEW CLIPPING
* ==============
* POINTER LOCK
* ------v------*/
updatePointerLockButton() {
// Only show the button if the pointer lock API is properly supported
// AND in fullscreen.
if (
UI.connected &&
(document.fullscreenElement || // alternative standard method
document.mozFullScreenElement || // currently working methods
document.webkitFullscreenElement ||
document.msFullscreenElement) &&
(document.pointerLockElement !== undefined ||
document.mozPointerLockElement !== undefined)
) {
document
.getElementById("noVNC_pointer_lock_button")
.classList.remove("noVNC_hidden");
} else {
document
.getElementById("noVNC_pointer_lock_button")
.classList.add("noVNC_hidden");
}
},
requestPointerLock() {
UI.rfb.requestInputLock({ pointer: true });
},
/* ------^-------
* /POINTER LOCK
* ==============
* VIEWDRAG
* ------v------*/
@ -1710,6 +1749,18 @@ const UI = {
document.title = e.detail.name + " - " + PAGE_TITLE;
},
inputLockChanged(e) {
if (e.detail.pointer) {
document
.getElementById("noVNC_pointer_lock_button")
.classList.add("noVNC_selected");
} else {
document
.getElementById("noVNC_pointer_lock_button")
.classList.remove("noVNC_selected");
}
},
bell(e) {
if (WebUtil.getConfigVar('bell', 'on') === 'on') {
const promise = document.getElementById('noVNC_bell').play();

View File

@ -30,6 +30,7 @@ export const encodings = {
pseudoEncodingCompressLevel9: -247,
pseudoEncodingCompressLevel0: -256,
pseudoEncodingVMwareCursor: 0x574d5664,
pseudoEncodingVMwareCursorPosition: 0x574d5666,
pseudoEncodingExtendedClipboard: 0xc0a1e5ce
};

View File

@ -183,6 +183,7 @@ export default class RFB extends EventTargetMixin {
this._mousePos = {};
this._mouseButtonMask = 0;
this._mouseLastMoveTime = 0;
this._pointerLock = false;
this._viewportDragging = false;
this._viewportDragPos = {};
this._viewportHasMoved = false;
@ -200,6 +201,8 @@ export default class RFB extends EventTargetMixin {
focusCanvas: this._focusCanvas.bind(this),
handleResize: this._handleResize.bind(this),
handleMouse: this._handleMouse.bind(this),
handlePointerLockChange: this._handlePointerLockChange.bind(this),
handlePointerLockError: this._handlePointerLockError.bind(this),
handleWheel: this._handleWheel.bind(this),
handleGesture: this._handleGesture.bind(this),
handleRSAAESCredentialsRequired: this._handleRSAAESCredentialsRequired.bind(this),
@ -481,6 +484,24 @@ export default class RFB extends EventTargetMixin {
this._canvas.blur();
}
requestInputLock(locks) {
if (locks.pointer) {
if (this._canvas.requestPointerLock) {
this._canvas.requestPointerLock();
return;
}
if (this._canvas.mozRequestPointerLock) {
this._canvas.mozRequestPointerLock();
return;
}
}
// If we were not able to request any lock, still let the user know
// about the result.
this.dispatchEvent(new CustomEvent(
"inputlock",
{ detail: { pointer: this._pointerLock }, }));
}
clipboardPasteFrom(text) {
if (this._rfbConnectionState !== 'connected' || this._viewOnly) { return; }
@ -548,6 +569,14 @@ export default class RFB extends EventTargetMixin {
// preventDefault() on mousedown doesn't stop this event for some
// reason so we have to explicitly block it
this._canvas.addEventListener('contextmenu', this._eventHandlers.handleMouse);
// Pointer Lock listeners need to be installed in document instead of the canvas.
if (document.onpointerlockchange !== undefined) {
document.addEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange, false);
document.addEventListener('pointerlockerror', this._eventHandlers.handlePointerLockError, false);
} else if (document.onmozpointerlockchange !== undefined) {
document.addEventListener('mozpointerlockchange', this._eventHandlers.handlePointerLockChange, false);
document.addEventListener('mozpointerlockerror', this._eventHandlers.handlePointerLockError, false);
}
// Wheel events
this._canvas.addEventListener("wheel", this._eventHandlers.handleWheel);
@ -572,6 +601,13 @@ export default class RFB extends EventTargetMixin {
this._canvas.removeEventListener('mousemove', this._eventHandlers.handleMouse);
this._canvas.removeEventListener('click', this._eventHandlers.handleMouse);
this._canvas.removeEventListener('contextmenu', this._eventHandlers.handleMouse);
if (document.onpointerlockchange !== undefined) {
document.removeEventListener('pointerlockchange', this._eventHandlers.handlePointerLockChange);
document.removeEventListener('pointerlockerror', this._eventHandlers.handlePointerLockError);
} else if (document.onmozpointerlockchange !== undefined) {
document.removeEventListener('mozpointerlockchange', this._eventHandlers.handlePointerLockChange);
document.removeEventListener('mozpointerlockerror', this._eventHandlers.handlePointerLockError);
}
this._canvas.removeEventListener("mousedown", this._eventHandlers.focusCanvas);
this._canvas.removeEventListener("touchstart", this._eventHandlers.focusCanvas);
this._resizeObserver.disconnect();
@ -980,8 +1016,27 @@ export default class RFB extends EventTargetMixin {
return;
}
let pos = clientToElement(ev.clientX, ev.clientY,
let pos;
if (this._pointerLock) {
pos = {
x: this._mousePos.x + ev.movementX,
y: this._mousePos.y + ev.movementY,
};
if (pos.x < 0) {
pos.x = 0;
} else if (pos.x > this._fbWidth) {
pos.x = this._fbWidth;
}
if (pos.y < 0) {
pos.y = 0;
} else if (pos.y > this._fbHeight) {
pos.y = this._fbHeight;
}
this._cursor.move(pos.x, pos.y);
} else {
pos = clientToElement(ev.clientX, ev.clientY,
this._canvas);
}
switch (ev.type) {
case 'mousedown':
@ -1082,6 +1137,28 @@ export default class RFB extends EventTargetMixin {
this._mouseLastMoveTime = Date.now();
}
_handlePointerLockChange() {
if (
document.pointerLockElement === this._canvas ||
document.mozPointerLockElement === this._canvas
) {
this._pointerLock = true;
this._cursor.setEmulateCursor(true);
} else {
this._pointerLock = false;
this._cursor.setEmulateCursor(false);
}
this.dispatchEvent(new CustomEvent(
"inputlock",
{ detail: { pointer: this._pointerLock }, }));
}
_handlePointerLockError() {
this.dispatchEvent(new CustomEvent(
"inputlock",
{ detail: { pointer: this._pointerLock }, }));
}
_sendMouse(x, y, mask) {
if (this._rfbConnectionState !== 'connected') { return; }
if (this._viewOnly) { return; } // View only, skip mouse events
@ -2065,6 +2142,8 @@ export default class RFB extends EventTargetMixin {
encs.push(encodings.pseudoEncodingCursor);
}
encs.push(encodings.pseudoEncodingVMwareCursorPosition);
RFB.messages.clientEncodings(this._sock, encs);
}
@ -2471,6 +2550,9 @@ export default class RFB extends EventTargetMixin {
case encodings.pseudoEncodingVMwareCursor:
return this._handleVMwareCursor();
case encodings.pseudoEncodingVMwareCursorPosition:
return this._handleVMwareCursorPosition();
case encodings.pseudoEncodingCursor:
return this._handleCursor();
@ -2609,6 +2691,19 @@ export default class RFB extends EventTargetMixin {
return true;
}
_handleVMwareCursorPosition() {
const x = this._FBU.x;
const y = this._FBU.y;
if (this._pointerLock) {
// Only attempt to match the server's pointer position if we are in
// pointer lock mode.
this._mousePos = { x: x, y: y };
}
return true;
}
_handleCursor() {
const hotx = this._FBU.x; // hotspot-x
const hoty = this._FBU.y; // hotspot-y

View File

@ -6,21 +6,20 @@
import { supportsCursorURIs, isTouchDevice } from './browser.js';
const useFallback = !supportsCursorURIs || isTouchDevice;
const needsFallback = !supportsCursorURIs || isTouchDevice;
export default class Cursor {
constructor() {
this._target = null;
this._canvas = document.createElement('canvas');
this._canvas.style.position = 'fixed';
this._canvas.style.zIndex = '65535';
this._canvas.style.pointerEvents = 'none';
// Can't use "display" because of Firefox bug #1445997
this._canvas.style.visibility = 'hidden';
if (useFallback) {
this._canvas.style.position = 'fixed';
this._canvas.style.zIndex = '65535';
this._canvas.style.pointerEvents = 'none';
// Can't use "display" because of Firefox bug #1445997
this._canvas.style.visibility = 'hidden';
}
this._useFallback = needsFallback;
this._position = { x: 0, y: 0 };
this._hotSpot = { x: 0, y: 0 };
@ -40,9 +39,14 @@ export default class Cursor {
this._target = target;
if (useFallback) {
document.body.appendChild(this._canvas);
document.body.appendChild(this._canvas);
if (needsFallback) {
// Only add the event listeners if this will be responsible for
// rendering the cursor all the time. Otherwise, the cursor will
// only be rendered then the forced emulation is turned on, and
// that doesn't require this class to be adjusting the cursor
// position.
const options = { capture: true, passive: true };
this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options);
this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options);
@ -58,16 +62,16 @@ export default class Cursor {
return;
}
if (useFallback) {
if (needsFallback) {
const options = { capture: true, passive: true };
this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options);
this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options);
this._target.removeEventListener('mousemove', this._eventHandlers.mousemove, options);
this._target.removeEventListener('mouseup', this._eventHandlers.mouseup, options);
document.body.removeChild(this._canvas);
}
document.body.removeChild(this._canvas);
this._target = null;
}
@ -91,9 +95,10 @@ export default class Cursor {
ctx.clearRect(0, 0, w, h);
ctx.putImageData(img, 0, 0);
if (useFallback) {
if (this._useFallback || needsFallback) {
this._updatePosition();
} else {
}
if (!needsFallback) {
let url = this._canvas.toDataURL();
this._target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default';
}
@ -112,7 +117,7 @@ export default class Cursor {
// Mouse events might be emulated, this allows
// moving the cursor in such cases
move(clientX, clientY) {
if (!useFallback) {
if (!this._useFallback) {
return;
}
// clientX/clientY are relative the _visual viewport_,
@ -130,6 +135,22 @@ export default class Cursor {
this._updateVisibility(target);
}
// Force the use of cursor emulation. This is needed when the pointer lock
// is in use, since the browser will not render the cursor.
setEmulateCursor(emulate) {
if (needsFallback) {
// We need to use the fallback all the time, so we shouldn't update
// the fallback flag.
return;
}
this._useFallback = emulate;
if (this._useFallback) {
this._showCursor();
} else {
this._hideCursor();
}
}
_handleMouseOver(event) {
// This event could be because we're entering the target, or
// moving around amongst its sub elements. Let the move handler

View File

@ -117,6 +117,10 @@ protocol stream.
- The `capabilities` event is fired when `RFB.capabilities` is
updated.
[`inputlock`](#inputlock)
- The `inputlock` event is fired when an input lock is acquired (or released)
by the canvas.
### Methods
[`RFB.disconnect()`](#rfbdisconnect)
@ -155,6 +159,9 @@ protocol stream.
[`RFB.clipboardPasteFrom()`](#rfbclipboardpastefrom)
- Send clipboard contents to server.
[`RFB.requestInputLock()`](#rfbrequestInputLock)
- Requests that the RFB canvas acquire an input lock.
### Details
#### RFB()
@ -285,6 +292,15 @@ The `capabilities` event is fired whenever an entry is added or removed
from `RFB.capabilities`. The `detail` property is an `Object` with the
property `capabilities` containing the new value of `RFB.capabilities`.
#### inputlock
The `inputlock` event is fired after a request to acquire an input lock or
whenever the state of the canvas' input lock has changed, the latter typically
occurs because the lock was released by the user pressing the ESC key or
performing a browser-specific gesture. The `detail` property is an `Object`
with the property `pointer` containing whether the Pointer Lock is currently
held or not.
#### RFB.disconnect()
The `RFB.disconnect()` method is used to disconnect from the currently
@ -423,3 +439,24 @@ to the remote server.
**`text`**
- A `DOMString` specifying the clipboard data to send.
#### RFB.requestInputLock()
The `RFB.requestInputLock()` method is used to request that the RFB canvas hold
an input lock. An `inputlock` event will be fired with the result of the
acquisition of the requested locks.
##### Syntax
RFB.requestInputLock( { pointer: true } );
###### Parameters
**`pointer`**
- Requests to acquire a [Pointer
Lock](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_Lock_API),
which hides the local mouse cursor and provides relative motion events.
This must be called directly from an event handler where a user has
directly interacted with an element through an [engagement
gesture](https://w3c.github.io/pointerlock/#dfn-engagement-gesture) (e.g. a
click or touch event) for the browser to allow this.

View File

@ -2725,6 +2725,27 @@ describe('Remote Frame Buffer Protocol Client', function () {
client._canvas.dispatchEvent(ev);
}
function supportsSendMouseMovementEvent() {
// Some browsers (like Safari) support the movementX /
// movementY properties of MouseEvent, but do not allow creation
// of non-trusted events with those properties.
let ev;
ev = new MouseEvent('mousemove',
{ 'movementX': 100,
'movementY': 100 });
return ev.movementX === 100 && ev.movementY === 100;
}
function sendMouseMovementEvent(dx, dy) {
let ev;
ev = new MouseEvent('mousemove',
{ 'movementX': dx,
'movementY': dy });
client._canvas.dispatchEvent(ev);
}
function sendMouseButtonEvent(x, y, down, button) {
let pos = elementToClient(x, y);
let ev;
@ -2838,6 +2859,62 @@ describe('Remote Frame Buffer Protocol Client', function () {
50, 70, 0x0);
});
it('should ignore remote cursor position updates', function () {
if (!supportsSendMouseMovementEvent()) {
this.skip();
return;
}
// Simple VMware Cursor Position FBU message with pointer coordinates
// (50, 50).
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0x00, 0x32, 0x00, 0x32,
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
client._resize(100, 100);
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
client._sock._websocket._receiveData(new Uint8Array(incoming));
expect(cursorSpy).to.have.been.calledOnceWith();
cursorSpy.restore();
expect(client._mousePos).to.deep.equal({ });
sendMouseMoveEvent(10, 10);
clock.tick(100);
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
10, 10, 0x0);
});
it('should handle remote mouse position updates in pointer lock mode', function () {
if (!supportsSendMouseMovementEvent()) {
this.skip();
return;
}
// Simple VMware Cursor Position FBU message with pointer coordinates
// (50, 50).
const incoming = [ 0x00, 0x00, 0x00, 0x01, 0x00, 0x32, 0x00, 0x32,
0x00, 0x00, 0x00, 0x00, 0x57, 0x4d, 0x56, 0x66 ];
client._resize(100, 100);
const spy = sinon.spy();
client.addEventListener("inputlock", spy);
let stub = sinon.stub(document, 'pointerLockElement');
stub.get(function () { return client._canvas; });
client._handlePointerLockChange();
stub.restore();
client._sock._websocket._receiveData(new Uint8Array([0x02, 0x02]));
expect(spy).to.have.been.calledOnce;
expect(spy.args[0][0].detail.pointer).to.be.true;
const cursorSpy = sinon.spy(client, '_handleVMwareCursorPosition');
client._sock._websocket._receiveData(new Uint8Array(incoming));
expect(cursorSpy).to.have.been.calledOnceWith();
cursorSpy.restore();
expect(client._mousePos).to.deep.equal({ x: 50, y: 50 });
sendMouseMovementEvent(10, 10);
clock.tick(100);
expect(pointerEvent).to.have.been.calledOnceWith(client._sock,
60, 60, 0x0);
});
describe('Event Aggregation', function () {
it('should send a single pointer event on mouse movement', function () {
sendMouseMoveEvent(50, 70);

View File

@ -84,6 +84,11 @@
id="noVNC_view_drag_button" class="noVNC_button noVNC_hidden"
title="Move/Drag Viewport">
<!-- Lock pointer events -->
<input type="image" alt="Lock pointer" src="app/images/pointer.svg"
id="noVNC_pointer_lock_button" class="noVNC_button noVNC_hidden"
title="Lock pointer">
<!--noVNC Touch Device only buttons-->
<div id="noVNC_mobile_buttons">
<input type="image" alt="Keyboard" src="app/images/keyboard.svg"