diff --git a/app/ui.js b/app/ui.js index 51e57bd3..7c6c3009 100644 --- a/app/ui.js +++ b/app/ui.js @@ -184,6 +184,7 @@ const UI = { UI.initSetting('shared', true); UI.initSetting('bell', 'on'); UI.initSetting('view_only', false); + UI.initSetting('show_remote_cursor', true); UI.initSetting('show_dot', false); UI.initSetting('path', 'websockify'); UI.initSetting('repeaterID', ''); @@ -369,6 +370,8 @@ const UI = { UI.addSettingChangeHandler('shared'); UI.addSettingChangeHandler('view_only'); UI.addSettingChangeHandler('view_only', UI.updateViewOnly); + UI.addSettingChangeHandler('show_remote_cursor'); + UI.addSettingChangeHandler('show_remote_cursor', UI.updateShowRemoteCursor); UI.addSettingChangeHandler('show_dot'); UI.addSettingChangeHandler('show_dot', UI.updateShowDotCursor); UI.addSettingChangeHandler('host'); @@ -441,6 +444,7 @@ const UI = { UI.disableSetting('port'); UI.disableSetting('path'); UI.disableSetting('repeaterID'); + UI.disableSetting('show_remote_cursor'); // Hide the controlbar after 2 seconds UI.closeControlbarTimeout = setTimeout(UI.closeControlbar, 2000); @@ -451,6 +455,7 @@ const UI = { UI.enableSetting('port'); UI.enableSetting('path'); UI.enableSetting('repeaterID'); + UI.enableSetting('show_remote_cursor'); UI.updatePowerButton(); UI.keepControlbar(); } @@ -887,6 +892,7 @@ const UI = { UI.updateSetting('compression'); UI.updateSetting('shared'); UI.updateSetting('view_only'); + UI.updateSetting('show_remote_cursor'); UI.updateSetting('path'); UI.updateSetting('repeaterID'); UI.updateSetting('logging'); @@ -1100,6 +1106,7 @@ const UI = { UI.rfb.resizeSession = UI.getSetting('resize') === 'remote'; UI.rfb.qualityLevel = parseInt(UI.getSetting('quality')); UI.rfb.compressionLevel = parseInt(UI.getSetting('compression')); + UI.rfb.showRemoteCursor = UI.getSetting('show_remote_cursor'); UI.rfb.showDotCursor = UI.getSetting('show_dot'); UI.updateViewOnly(); // requires UI.rfb @@ -1754,6 +1761,11 @@ const UI = { } }, + updateShowRemoteCursor () { + if (!UI.rfb) return; + UI.rfb.showRemoteCursor = UI.getSetting('show_remote_cursor'); + }, + updateShowDotCursor() { if (!UI.rfb) return; UI.rfb.showDotCursor = UI.getSetting('show_dot'); diff --git a/core/encodings.js b/core/encodings.js index 7afcb17f..d56db734 100644 --- a/core/encodings.js +++ b/core/encodings.js @@ -34,7 +34,10 @@ export const encodings = { pseudoEncodingCompressLevel9: -247, pseudoEncodingCompressLevel0: -256, pseudoEncodingVMwareCursor: 0x574d5664, - pseudoEncodingExtendedClipboard: 0xc0a1e5ce + pseudoEncodingExtendedClipboard: 0xc0a1e5ce, + pseudoEncodingRichCursor: 0xffffff11, + pseudoEncodingPointerPos: 0xffffff18, + pseudoEncodingTightPointerPos: -232 }; export function encodingName(num) { diff --git a/core/rfb.js b/core/rfb.js index 80011e4a..41f3a0c1 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -309,7 +309,7 @@ export default class RFB extends EventTargetMixin { get viewOnly() { return this._viewOnly; } set viewOnly(viewOnly) { - this._viewOnly = viewOnly; + this._viewOnly = this._cursor.viewOnly = viewOnly; if (this._rfbConnectionState === "connecting" || this._rfbConnectionState === "connected") { @@ -413,6 +413,11 @@ export default class RFB extends EventTargetMixin { } } + get showRemoteCursor() { return this._showRemoteCursor; } + set showRemoteCursor(show) { + this._showRemoteCursor = show; + } + // ===== PUBLIC METHODS ===== disconnect() { @@ -2264,6 +2269,12 @@ export default class RFB extends EventTargetMixin { if (this._fbDepth == 24) { encs.push(encodings.pseudoEncodingVMwareCursor); encs.push(encodings.pseudoEncodingCursor); + encs.push(encodings.pseudoEncodingRichCursor); + } + + if (this._showRemoteCursor) { + encs.push(encodings.pseudoEncodingPointerPos); + encs.push(encodings.pseudoEncodingTightPointerPos); } RFB.messages.clientEncodings(this._sock, encs); @@ -2673,6 +2684,7 @@ export default class RFB extends EventTargetMixin { return this._handleVMwareCursor(); case encodings.pseudoEncodingCursor: + case encodings.pseudoEncodingRichCursor: return this._handleCursor(); case encodings.pseudoEncodingQEMUExtendedKeyEvent: @@ -2696,6 +2708,10 @@ export default class RFB extends EventTargetMixin { case encodings.pseudoEncodingQEMULedEvent: return this._handleLedEvent(); + case encodings.pseudoEncodingPointerPos: + case encodings.pseudoEncodingTightPointerPos: + return this._handlePointerPos(); + default: return this._handleDataRect(); } @@ -2888,6 +2904,15 @@ export default class RFB extends EventTargetMixin { return true; } + _handlePointerPos() { + const x = this._FBU.x; + const y = this._FBU.y; + + this._cursor.moveRemote(x, y, this._display.scale); + + return true; + } + _handleExtendedDesktopSize() { if (this._sock.rQwait("ExtendedDesktopSize", 4)) { return false; diff --git a/core/util/cursor.js b/core/util/cursor.js index 6d689e7d..62c95b9d 100644 --- a/core/util/cursor.js +++ b/core/util/cursor.js @@ -14,17 +14,15 @@ export default class Cursor { this._canvas = document.createElement('canvas'); - if (useFallback) { - this._canvas.style.position = 'fixed'; - this._canvas.style.zIndex = '65535'; - this._canvas.style.pointerEvents = 'none'; - // Safari on iOS can select the cursor image - // https://bugs.webkit.org/show_bug.cgi?id=249223 - this._canvas.style.userSelect = 'none'; - this._canvas.style.WebkitUserSelect = 'none'; - // Can't use "display" because of Firefox bug #1445997 - this._canvas.style.visibility = 'hidden'; - } + this._canvas.style.position = 'fixed'; + this._canvas.style.zIndex = '65535'; + this._canvas.style.pointerEvents = 'none'; + // Safari on iOS can select the cursor image + // https://bugs.webkit.org/show_bug.cgi?id=249223 + this._canvas.style.userSelect = 'none'; + this._canvas.style.WebkitUserSelect = 'none'; + // Can't use "display" because of Firefox bug #1445997 + this._canvas.style.visibility = 'hidden'; this._position = { x: 0, y: 0 }; this._hotSpot = { x: 0, y: 0 }; @@ -35,6 +33,20 @@ export default class Cursor { 'mousemove': this._handleMouseMove.bind(this), 'mouseup': this._handleMouseUp.bind(this), }; + + this._mouseOver = false; + this._viewOnly = false; + } + + get viewOnly() { return this._viewOnly; } + set viewOnly(viewOnly) { + if (viewOnly !== this._viewOnly) { + this._viewOnly = viewOnly; + this._resetNativeCursorStyle(); + if (this._viewOnly) { + this._showCursor(); + } + } } attach(target) { @@ -44,12 +56,13 @@ export default class Cursor { this._target = target; - if (useFallback) { - document.body.appendChild(this._canvas); + document.body.appendChild(this._canvas); - const options = { capture: true, passive: true }; - this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options); - this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options); + const options = { capture: true, passive: true }; + this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options); + this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options); + + if (useFallback) { this._target.addEventListener('mousemove', this._eventHandlers.mousemove, options); this._target.addEventListener('mouseup', this._eventHandlers.mouseup, options); } @@ -62,16 +75,17 @@ export default class Cursor { return; } + const options = { capture: true, passive: true }; + this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options); + this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options); + if (useFallback) { - 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); + } - if (document.contains(this._canvas)) { - document.body.removeChild(this._canvas); - } + if (document.contains(this._canvas)) { + document.body.removeChild(this._canvas); } this._target = null; @@ -97,16 +111,17 @@ export default class Cursor { ctx.clearRect(0, 0, w, h); ctx.putImageData(img, 0, 0); - if (useFallback) { + if (useFallback || this._viewOnly || !this._mouseOver) { this._updatePosition(); - } else { + } + if (!useFallback && !this._viewOnly) { let url = this._canvas.toDataURL(); this._target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default'; } } clear() { - this._target.style.cursor = 'none'; + this._resetNativeCursorStyle(); this._canvas.width = 0; this._canvas.height = 0; this._position.x = this._position.x + this._hotSpot.x; @@ -115,6 +130,12 @@ export default class Cursor { this._hotSpot.y = 0; } + _resetNativeCursorStyle() { + if (this._target) { + this._target.style.cursor = this._viewOnly ? 'not-allowed' : 'none'; + } + } + // Mouse events might be emulated, this allows // moving the cursor in such cases move(clientX, clientY) { @@ -136,19 +157,58 @@ export default class Cursor { this._updateVisibility(target); } + moveRemote(remoteX, remoteY, scale) { + if (this._mouseOver && !this._viewOnly) { + return; + } + + let targetBounds = this._target.getBoundingClientRect(); + this._position.x = targetBounds.left + remoteX * scale - this._hotSpot.x; + this._position.y = targetBounds.top + remoteY * scale - this._hotSpot.y; + + this._updatePosition(); + } + _handleMouseOver(event) { // This event could be because we're entering the target, or // moving around amongst its sub elements. Let the move handler // sort things out. + this._mouseOver = true; this._handleMouseMove(event); } _handleMouseLeave(event) { + if (this._viewOnly) { + return; + } + + let targetBounds = this._getVisibleBoundingRect(this._target); + this._mouseOver = event.clientX >= targetBounds.left && event.clientX < targetBounds.right && + event.clientY >= targetBounds.top && event.clientY < targetBounds.bottom; // Check if we should show the cursor on the element we are leaving to this._updateVisibility(event.relatedTarget); } + _getVisibleBoundingRect(element) { + let rect = element.getBoundingClientRect(); + let bounds = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; + if (element.parentElement) { + let parentBounds = element.parentElement.getBoundingClientRect(); + bounds = { + left: Math.max(bounds.left, parentBounds.left), + top: Math.max(bounds.top, parentBounds.top), + right: Math.min(bounds.right, parentBounds.right), + bottom: Math.min(bounds.bottom, parentBounds.bottom) + }; + } + return bounds; + } + _handleMouseMove(event) { + if (this._viewOnly) { + return; + } + this._updateVisibility(event.target); this._position.x = event.clientX - this._hotSpot.x; @@ -158,6 +218,10 @@ export default class Cursor { } _handleMouseUp(event) { + if (this._viewOnly) { + return; + } + // We might get this event because of a drag operation that // moved outside of the target. Check what's under the cursor // now and adjust visibility based on that. @@ -230,7 +294,7 @@ export default class Cursor { if (this._captureIsActive()) { target = document.captureElement; } - if (this._shouldShowCursor(target)) { + if (!this._mouseOver || (useFallback && this._shouldShowCursor(target))) { this._showCursor(); } else { this._hideCursor(); diff --git a/docs/API.md b/docs/API.md index eb3ec333..17ffedad 100644 --- a/docs/API.md +++ b/docs/API.md @@ -77,6 +77,10 @@ protocol stream. if the remote session is smaller than its container, or handled according to `clipViewport` if it is larger. Disabled by default. +`showRemoteCursor` + - Is a `boolean` indicating whether the remote cursor position should + be tracked. The server must support the respective pseudo encoding. + `showDotCursor` - Is a `boolean` indicating whether a dot cursor should be shown instead of a zero-sized or fully-transparent cursor if the server diff --git a/vnc.html b/vnc.html index 82cacd58..8625196c 100644 --- a/vnc.html +++ b/vnc.html @@ -289,6 +289,13 @@

  • +
  • + +