From c9692e54244d2dd3c3324da0dc77053712f60cb3 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Thu, 26 Oct 2023 18:21:09 -0700 Subject: [PATCH 1/5] Add touchpad mobile button to the control bar. - Add touchpad.svg to images. - Add #noVNC_touchpad_button button to #noVNC_mobile_buttons div. - Add/modify css rules so margin applies to mobile buttons, not the surrounding div. --- app/images/touchpad.svg | 62 +++++++++++++++++++++++++++++++++++++++++ app/styles/base.css | 11 +++++++- vnc.html | 3 ++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 app/images/touchpad.svg diff --git a/app/images/touchpad.svg b/app/images/touchpad.svg new file mode 100644 index 000000000..0bdd7a1fe --- /dev/null +++ b/app/images/touchpad.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + diff --git a/app/styles/base.css b/app/styles/base.css index f83ad4b93..7a74ff7de 100644 --- a/app/styles/base.css +++ b/app/styles/base.css @@ -345,7 +345,10 @@ html { padding: 0 10px; } -#noVNC_control_bar > .noVNC_scroll > * { +/* Do not apply margin to #noVNC_mobilebuttons div. There is now + more than one button inside it, so we'll apply margins directly + to the buttons in another rule. */ +#noVNC_control_bar > .noVNC_scroll > *:not(#noVNC_mobile_buttons) { display: block; margin: 10px auto; } @@ -553,6 +556,12 @@ html { :root:not(.noVNC_connected) #noVNC_mobile_buttons { display: none; } + +#noVNC_mobile_buttons > .noVNC_button { + display: block; + margin: 10px auto; +} + @media not all and (any-pointer: coarse) { /* FIXME: The button for the virtual keyboard is the only button in this group of "mobile buttons". It is bad to assume that no touch diff --git a/vnc.html b/vnc.html index 24a118dbd..6ac6e63b8 100644 --- a/vnc.html +++ b/vnc.html @@ -79,6 +79,9 @@

no
VNC

+ +
From 810d294b18c51f3c10136600c1e83154733049b2 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Fri, 27 Oct 2023 05:31:31 -0700 Subject: [PATCH 2/5] Make touchpad button togglable. --- app/ui.js | 36 +++++++++++++++++++++++++++++++----- core/rfb.js | 1 + 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/ui.js b/app/ui.js index 85695ca2e..a7fd03c5f 100644 --- a/app/ui.js +++ b/app/ui.js @@ -232,6 +232,9 @@ const UI = { document.getElementById("noVNC_view_drag_button") .addEventListener('click', UI.toggleViewDrag); + document.getElementById("noVNC_touchpad_button") + .addEventListener('click', UI.toggleTouchpadMode); + document.getElementById("noVNC_control_bar_handle") .addEventListener('mousedown', UI.controlbarHandleMouseDown); document.getElementById("noVNC_control_bar_handle") @@ -1586,11 +1589,34 @@ const UI = { } }, -/* ------^------- - * /KEYBOARD - * ============== - * EXTRA KEYS - * ------v------*/ + /* ------^------- + * /KEYBOARD + * ============== + * TOUCHPAD + * ------v------*/ + + toggleTouchpadMode() { + if (!UI.rfb) return; + + UI.rfb.touchpadMode = !UI.rfb.touchpadMode; + UI.updateTouchpadButton(); + }, + + updateTouchpadButton() { + const touchpadButton = document.getElementById('noVNC_touchpad_button'); + + if (UI.rfb.touchpadMode) { + touchpadButton.classList.add("noVNC_selected"); + } else { + touchpadButton.classList.remove("noVNC_selected"); + } + }, + + /* ------^------- + * /TOUCHPAD + * ============== + * EXTRA KEYS + * ------v------*/ openExtraKeys() { UI.closeAllPanels(); diff --git a/core/rfb.js b/core/rfb.js index c71d6b88f..443fd9309 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -290,6 +290,7 @@ export default class RFB extends EventTargetMixin { this._clippingViewport = false; this._scaleViewport = false; this._resizeSession = false; + this.touchpadMode = false; this._showDotCursor = false; if (options.showDotCursor !== undefined) { From 6175985967bff83eac0d13e9f36c262c17deacb9 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Sun, 4 Feb 2024 11:17:43 -0800 Subject: [PATCH 3/5] Implement TouchpadMode gesture handling. --- app/ui.js | 58 ++++++- core/display.js | 34 ++++ core/input/gesturehandler.js | 62 ++++++- core/rfb.js | 302 +++++++++++++++++++++++++++++++++-- core/util/browser.js | 23 ++- core/util/cursor.js | 19 ++- 6 files changed, 457 insertions(+), 41 deletions(-) diff --git a/app/ui.js b/app/ui.js index a7fd03c5f..94ef2b548 100644 --- a/app/ui.js +++ b/app/ui.js @@ -88,7 +88,7 @@ const UI = { }); // Adapt the interface for touch screen devices - if (isTouchDevice) { + if (isTouchDevice()) { // Remove the address bar setTimeout(() => window.scrollTo(0, 1), 100); } @@ -464,6 +464,12 @@ const UI = { .classList.remove('noVNC_open'); }, + /** + * @param {string} text + * @param { "normal" | "info" | "warn" | "warning" | "error" } statusType + * @param {number} time + * @returns + */ showStatus(text, statusType, time) { const statusElem = document.getElementById('noVNC_status'); @@ -1064,8 +1070,10 @@ const UI = { UI.rfb.qualityLevel = parseInt(UI.getSetting('quality')); UI.rfb.compressionLevel = parseInt(UI.getSetting('compression')); UI.rfb.showDotCursor = UI.getSetting('show_dot'); + UI.rfb.touchpadMode = WebUtil.readSetting('touchpad_mode', 'false') === 'true'; UI.updateViewOnly(); // requires UI.rfb + UI.updateTouchpadMode(); }, disconnect() { @@ -1119,6 +1127,12 @@ const UI = { // Do this last because it can only be used on rendered elements UI.rfb.focus(); + + // In touchpad mode, we want the cursor centered in the + // viewport at the start so we can see it. + if (UI.rfb.touchpadMode) { + UI.rfb.centerCursorInViewport(); + } }, disconnectFinished(e) { @@ -1348,7 +1362,7 @@ const UI = { // Can't be clipping if viewport is scaled to fit UI.forceSetting('view_clip', false); UI.rfb.clipViewport = false; - } else if (brokenScrollbars) { + } else if (brokenScrollbars || UI.rfb.touchpadMode) { UI.forceSetting('view_clip', true); UI.rfb.clipViewport = true; } else { @@ -1372,6 +1386,7 @@ const UI = { UI.rfb.dragViewport = !UI.rfb.dragViewport; UI.updateViewDrag(); + UI.updateTouchpadMode(); }, updateViewDrag() { @@ -1379,6 +1394,10 @@ const UI = { const viewDragButton = document.getElementById('noVNC_view_drag_button'); + if (UI.rfb.dragViewport) { + UI.rfb.touchpadMode = false; + } + if ((!UI.rfb.clipViewport || !UI.rfb.clippingViewport) && UI.rfb.dragViewport) { // We are no longer clipping the viewport. Make sure @@ -1432,7 +1451,7 @@ const UI = { * ------v------*/ showVirtualKeyboard() { - if (!isTouchDevice) return; + if (!isTouchDevice()) return; const input = document.getElementById('noVNC_keyboardinput'); @@ -1450,7 +1469,7 @@ const UI = { }, hideVirtualKeyboard() { - if (!isTouchDevice) return; + if (!isTouchDevice()) return; const input = document.getElementById('noVNC_keyboardinput'); @@ -1599,12 +1618,33 @@ const UI = { if (!UI.rfb) return; UI.rfb.touchpadMode = !UI.rfb.touchpadMode; - UI.updateTouchpadButton(); + WebUtil.writeSetting('touchpad_mode', UI.rfb.touchpadMode); + UI.updateTouchpadMode(); + UI.updateViewDrag(); }, - updateTouchpadButton() { + updateTouchpadMode() { + if (UI.rfb.touchpadMode) { + UI.rfb.dragViewport = false; + + UI.forceSetting('resize', 'off'); + UI.forceSetting('view_clip', true); + UI.forceSetting('show_dot', true); + + UI.rfb.clipViewport = true; + UI.rfb.scaleViewport = false; + UI.rfb.resizeSession = false; + UI.rfb.showDotCursor = true; + } + else { + UI.enableSetting('resize'); + UI.enableSetting('view_clip'); + UI.enableSetting('show_dot'); + } + + UI.updateViewDrag + const touchpadButton = document.getElementById('noVNC_touchpad_button'); - if (UI.rfb.touchpadMode) { touchpadButton.classList.add("noVNC_selected"); } else { @@ -1730,12 +1770,14 @@ const UI = { .classList.add('noVNC_hidden'); document.getElementById('noVNC_clipboard_button') .classList.add('noVNC_hidden'); + document.getElementById('noVNC_clipboard_button') + .classList.add('noVNC_hidden'); } else { document.getElementById('noVNC_keyboard_button') .classList.remove('noVNC_hidden'); document.getElementById('noVNC_toggle_extra_keys_button') .classList.remove('noVNC_hidden'); - document.getElementById('noVNC_clipboard_button') + document.getElementById('noVNC_touchpad_button') .classList.remove('noVNC_hidden'); } }, diff --git a/core/display.js b/core/display.js index fcd626999..c8704ebc3 100644 --- a/core/display.js +++ b/core/display.js @@ -87,8 +87,38 @@ export default class Display { return this._fbHeight; } + get viewportLocation() { + return this._viewportLoc; + } + // ===== PUBLIC METHODS ===== + /** + * Attempt to move the viewport by the specified amounts + * and returns the amount of actual position change. + * @param {number} moveByX + * @param {number} moveByY + * @return {{ x: number, y: number }} + */ + viewportTryMoveBy(moveByX, moveByY) { + if (moveByX === 0 && moveByY === 0) { + return { + x: 0, + y: 0 + } + } + + const vpX = this._viewportLoc.x; + const vpY = this._viewportLoc.y; + + this.viewportChangePos(moveByX, moveByY); + + return { + x: this._viewportLoc.x - vpX, + y: this._viewportLoc.y - vpY + } + } + viewportChangePos(deltaX, deltaY) { const vp = this._viewportLoc; deltaX = Math.floor(deltaX); @@ -433,6 +463,10 @@ export default class Display { this._rescale(scaleRatio); } + rescale(factor) { + this._rescale(factor); + } + // ===== PRIVATE METHODS ===== _rescale(factor) { diff --git a/core/input/gesturehandler.js b/core/input/gesturehandler.js index 6fa72d2aa..73e681cc0 100644 --- a/core/input/gesturehandler.js +++ b/core/input/gesturehandler.js @@ -18,7 +18,6 @@ const GH_PINCH = 64; const GH_INITSTATE = 127; -const GH_MOVE_THRESHOLD = 50; const GH_ANGLE_THRESHOLD = 90; // Degrees // Timeout when waiting for gestures (ms) @@ -38,6 +37,7 @@ export default class GestureHandler { this._target = null; this._state = GH_INITSTATE; + this._touchpadMode = false; this._tracked = []; this._ignored = []; @@ -51,6 +51,37 @@ export default class GestureHandler { this._boundEventHandler = this._eventHandler.bind(this); } + // ===== PROPERTIES ===== + + /** + * @returns {boolean} + */ + get touchpadMode() { + return this._touchpadMode; + } + + /** + * @param {boolean} enabled + */ + set touchpadMode(enabled) { + this._touchpadMode = enabled; + } + + /** + * @returns {number} + */ + get _ghMoveThreshold() { + // In TouchpadMode, we want movements to be very precise, + // so we'll reduce the movement threshold. + if (this._touchpadMode) { + return 5; + } + + return 50; + } + + // ===== PUBLIC METHODS ===== + attach(target) { this.detach(); @@ -64,7 +95,6 @@ export default class GestureHandler { this._target.addEventListener('touchcancel', this._boundEventHandler); } - detach() { if (!this._target) { return; @@ -84,6 +114,10 @@ export default class GestureHandler { this._target = null; } + /** + * + * @param {TouchEvent} e + */ _eventHandler(e) { let fn; @@ -102,7 +136,6 @@ export default class GestureHandler { fn = this._touchEnd; break; } - for (let i = 0; i < e.changedTouches.length; i++) { let touch = e.changedTouches[i]; fn.call(this, touch.identifier, touch.clientX, touch.clientY); @@ -142,9 +175,11 @@ export default class GestureHandler { firstY: y, lastX: x, lastY: y, - angle: 0 + movementX: 0, + movementY: 0, + angle: 0, }); - + switch (this._tracked.length) { case 1: this._startLongpressTimeout(); @@ -164,7 +199,7 @@ export default class GestureHandler { } } - _touchMove(id, x, y) { + _touchMove(id, x, y) { let touch = this._tracked.find(t => t.id === id); // If this is an update for a touch we're not tracking, ignore it @@ -173,6 +208,8 @@ export default class GestureHandler { } // Update the touches last position with the event coordinates + touch.movementX = x - touch.lastX; + touch.movementY = y - touch.lastY; touch.lastX = x; touch.lastY = y; @@ -187,7 +224,7 @@ export default class GestureHandler { if (!this._hasDetectedGesture()) { // Ignore moves smaller than the minimum threshold - if (Math.hypot(deltaX, deltaY) < GH_MOVE_THRESHOLD) { + if (Math.hypot(deltaX, deltaY) < this._ghMoveThreshold) { return; } @@ -216,7 +253,7 @@ export default class GestureHandler { // We know that the current touch moved far enough, // but unless both touches moved further than their // threshold we don't want to disqualify any gestures - if (prevDeltaMove > GH_MOVE_THRESHOLD) { + if (prevDeltaMove > this._ghMoveThreshold) { // The angle difference between the direction of the touch points let deltaAngle = Math.abs(touch.angle - prevTouch.angle); @@ -458,6 +495,15 @@ export default class GestureHandler { detail['clientX'] = pos.x; detail['clientY'] = pos.y; + if (this._touchpadMode && + this._tracked.length === 1) { + + const touch = this._tracked[0]; + + detail['movementX'] = touch.movementX; + detail['movementY'] = touch.movementY; + } + // FIXME: other coordinates? // Some gestures also have a magnitude diff --git a/core/rfb.js b/core/rfb.js index 443fd9309..5dc8e95b7 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -188,12 +188,16 @@ export default class RFB extends EventTargetMixin { this._viewportHasMoved = false; this._accumulatedWheelDeltaX = 0; this._accumulatedWheelDeltaY = 0; - + // Gesture state this._gestureLastTapTime = null; this._gestureFirstDoubleTapEv = null; this._gestureLastMagnitudeX = 0; this._gestureLastMagnitudeY = 0; + this._isTouchpadDragging = false; + this._touchpadTapTimeoutId = null; + this._lastTouchpadPinchMagnitude = 0; + this._currentPinchScale = 1; // Bound event handlers this._eventHandlers = { @@ -290,7 +294,7 @@ export default class RFB extends EventTargetMixin { this._clippingViewport = false; this._scaleViewport = false; this._resizeSession = false; - this.touchpadMode = false; + this._touchpadMode = false; this._showDotCursor = false; if (options.showDotCursor !== undefined) { @@ -409,6 +413,24 @@ export default class RFB extends EventTargetMixin { this._sendEncodings(); } } + + /** + * @returns {boolean} + */ + get touchpadMode() { + return this._touchpadMode; + } + + /** + * @param {boolean} enabled + */ + set touchpadMode(enabled) { + if (!this._gestures) { + return; + } + this._touchpadMode = enabled; + this._gestures.touchpadMode = enabled; + } // ===== PUBLIC METHODS ===== @@ -530,6 +552,17 @@ export default class RFB extends EventTargetMixin { } } + centerCursorInViewport() { + const container = document.getElementById('noVNC_container'); + const containerBounds = container.getBoundingClientRect(); + const x = containerBounds.left + (containerBounds.width * .5); + const y = containerBounds.top + (containerBounds.height * .5) + this._cursor.move(x, y); + + const elementPos = clientToElement(x, y, this._canvas); + this._handleMouseMove(elementPos.x, elementPos.y); + } + getImageData() { return this._display.getImageData(); } @@ -1264,21 +1297,57 @@ export default class RFB extends EventTargetMixin { case 'gesturestart': switch (ev.detail.type) { case 'onetap': - this._handleTapEvent(ev, 0x1); + if (this._touchpadMode) { + this._handleTouchpadOneTapEvent(); + } + else { + this._handleTapEvent(ev, 0x1); + } break; case 'twotap': + if (this._touchpadMode) { + this._sendTouchpadTwoTap(); + break; + } this._handleTapEvent(ev, 0x4); break; case 'threetap': + if (this._touchpadMode) { + this._sendTouchpadThreeTap(); + break; + } this._handleTapEvent(ev, 0x2); break; case 'drag': + // In TouchpadMode, we don't want to move the cursor + // at the start of dragging. It should remain at its + // current location. We'll only press the left mouse + // button if this is the second tap in a double-tap + // sequence. + if (this._touchpadMode) { + if (this._touchpadTapTimeoutId > 0) { + this._clearTouchpadTapTimeoutId(); + this._isTouchpadDragging = true; + this._mouseButtonMask = 0x1; + const cursorPos = this._getCursorPositionToCanvas(); + this._sendMouse(cursorPos.x, cursorPos.y, 0x1); + } + break; + } this._fakeMouseMove(ev, pos.x, pos.y); this._handleMouseButton(pos.x, pos.y, true, 0x1); break; case 'longpress': - this._fakeMouseMove(ev, pos.x, pos.y); - this._handleMouseButton(pos.x, pos.y, true, 0x4); + // In TouchpadMode, we want to start the right-click at the + // current cursor location. + if (this._touchpadMode) { + const cursorPos = this._getCursorPositionToCanvas(); + this._handleMouseButton(cursorPos.x, cursorPos.y, true, 0x4); + } + else { + this._fakeMouseMove(ev, pos.x, pos.y); + this._handleMouseButton(pos.x, pos.y, true, 0x4); + } break; case 'twodrag': @@ -1287,8 +1356,15 @@ export default class RFB extends EventTargetMixin { this._fakeMouseMove(ev, pos.x, pos.y); break; case 'pinch': - this._gestureLastMagnitudeX = Math.hypot(ev.detail.magnitudeX, - ev.detail.magnitudeY); + magnitude = Math.hypot( + ev.detail.magnitudeX, + ev.detail.magnitudeY); + + if (this._touchpadMode) { + this._lastTouchpadPinchMagnitude = magnitude; + break; + } + this._gestureLastMagnitudeX = magnitude; this._fakeMouseMove(ev, pos.x, pos.y); break; } @@ -1302,13 +1378,22 @@ export default class RFB extends EventTargetMixin { break; case 'drag': case 'longpress': - this._fakeMouseMove(ev, pos.x, pos.y); + // In TouchpadMode, we want to move the cursor from its + // current position, not to where the touch currently is. + if (this._touchpadMode) { + this._handleTouchpadMove(ev.detail.movementX, ev.detail.movementY); + } + else { + this._fakeMouseMove(ev, pos.x, pos.y); + } break; case 'twodrag': // Always scroll in the same position. // We don't know if the mouse was moved so we need to move it // every update. - this._fakeMouseMove(ev, pos.x, pos.y); + if (!this._touchpadMode) { + this._fakeMouseMove(ev, pos.x, pos.y); + } while ((ev.detail.magnitudeY - this._gestureLastMagnitudeY) > GESTURE_SCRLSENS) { this._handleMouseButton(pos.x, pos.y, true, 0x8); this._handleMouseButton(pos.x, pos.y, false, 0x8); @@ -1331,11 +1416,18 @@ export default class RFB extends EventTargetMixin { } break; case 'pinch': + magnitude = Math.hypot(ev.detail.magnitudeX, ev.detail.magnitudeY); + + if (this._touchpadMode) { + this._handleTouchpadPinchZoom(magnitude); + break; + } + // Always scroll in the same position. // We don't know if the mouse was moved so we need to move it // every update. this._fakeMouseMove(ev, pos.x, pos.y); - magnitude = Math.hypot(ev.detail.magnitudeX, ev.detail.magnitudeY); + if (Math.abs(magnitude - this._gestureLastMagnitudeX) > GESTURE_ZOOMSENS) { this._handleKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true); while ((magnitude - this._gestureLastMagnitudeX) > GESTURE_ZOOMSENS) { @@ -1363,18 +1455,204 @@ export default class RFB extends EventTargetMixin { case 'twodrag': break; case 'drag': + if (this._touchpadMode) { + if (this._isTouchpadDragging) { + this._mouseButtonMask = 0; + const cursorPos = this._getCursorPositionToCanvas(); + this._sendMouse(cursorPos.x, cursorPos.y, 0); + this._isTouchpadDragging = false; + } + break; + } this._fakeMouseMove(ev, pos.x, pos.y); this._handleMouseButton(pos.x, pos.y, false, 0x1); break; case 'longpress': - this._fakeMouseMove(ev, pos.x, pos.y); - this._handleMouseButton(pos.x, pos.y, false, 0x4); + // In TouchPad mode, we want to finish at the current cursor location. + if (this._touchpadMode) { + const cursorPos = this._getCursorPositionToCanvas(); + this._handleMouseButton(cursorPos.x, cursorPos.y, false, 0x4); + } + else { + this._fakeMouseMove(ev, pos.x, pos.y); + this._handleMouseButton(pos.x, pos.y, false, 0x4); + } break; } break; } } + // TouchpadMode Private Methods + + /** + * @param {number} movementX + * @param {number} movementY + */ + _handleTouchpadMove(movementX, movementY) { + + // Add a multiplier to higher-velocity movements to + // traverse the screen quicker. + const xMultiplier = Math.max(5, Math.abs(movementX)) / 5; + movementX *= Math.min(xMultiplier, 4); + + const yMultiplier = Math.max(5, Math.abs(movementY)) / 5; + movementY *= Math.min(yMultiplier, 4); + + // Get the desired new location for the cursor. + let cursorPos = this._cursor.position; + let targetX = cursorPos.x + movementX; + let targetY = cursorPos.y + movementY; + + // Constrain the location to the canvas bounds. + const canvasBounds = this._canvas.getBoundingClientRect(); + const safeX = Math.max(canvasBounds.left, Math.min(targetX, canvasBounds.right)); + const safeY = Math.max(canvasBounds.top, Math.min(targetY, canvasBounds.bottom)); + + // See if the cursor has moved outside the center deadzone. + const deadzone = this._getTouchpadCursorDeadZone(); + const moveViewportX = + Math.min(safeX - deadzone.left, 0) + + Math.max(safeX - deadzone.right, 0); + + const moveViewportY = + Math.min(safeY - deadzone.top, 0) + + Math.max(safeY - deadzone.bottom, 0); + + // Try moving the viewport, getting the actual amount it moved. + const viewportChange = this._display.viewportTryMoveBy(moveViewportX, moveViewportY); + + // Subtract the viewport position change from the target + // cursor position. This will cause it to stay at the + // edge of the deadzone if we're pushing against it, or + // move past it to the edge of the screen if the viewport + // can pan no further. + this._cursor.move(safeX - viewportChange.x, safeY - viewportChange.y); + + // Finally, translate the coordinates to those relative to the + // canvas and send the pointer move event to the remote machine. + const posFromCanvas = clientToElement(safeX, safeY, this._canvas); + this._sendMouse(posFromCanvas.x, posFromCanvas.y, this._mouseButtonMask); + } + + _handleTouchpadOneTapEvent() { + if (this._touchpadTapTimeoutId > 0) { + // A double-tap occurred. + this._clearTouchpadTapTimeoutId(); + this._sendTouchpadTap(); + this._sendTouchpadTap(); + return; + } + + this._touchpadTapTimeoutId = window.setTimeout(() => { + this._clearTouchpadTapTimeoutId(); + this._sendTouchpadTap(); + }, 250); + + } + + /** + * + * @param {number} magnitude + */ + _handleTouchpadPinchZoom(magnitude) { + if (this._lastTouchpadPinchMagnitude > 0) { + // Calculate the new pinch scale. + const container = document.getElementById('noVNC_container'); + const magnitudeChange = this._lastTouchpadPinchMagnitude / magnitude; + const newScale = this._currentPinchScale * magnitudeChange; + this._currentPinchScale = Math.max(.25, Math.min(4, newScale)); + + // Capture the current viewport size. + const originalVpW = this._display.viewportLocation.w; + const originalVpH = this._display.viewportLocation.h; + + // Change viewport size based on new scale. + const newWidth = container.clientWidth * this._currentPinchScale; + const newHeight = container.clientHeight * this._currentPinchScale; + this._display.viewportChangeSize(newWidth, newHeight); + + // Apply scaling to CSS. + const visualScale = container.clientWidth / newWidth; + this._display.rescale(visualScale); + + // Adjust viewport location to keep it centered. + const moveX = (originalVpW - this._display.viewportLocation.w) / 2; + const moveY = (originalVpH - this._display.viewportLocation.h) / 2; + this._display.viewportChangePos(moveX, moveY); + } + this._lastTouchpadPinchMagnitude = magnitude; + } + + _clearTouchpadTapTimeoutId() { + window.clearTimeout(this._touchpadTapTimeoutId); + this._touchpadTapTimeoutId = 0; + } + + _sendTouchpadTap() { + const cursorPos = this._getCursorPositionToCanvas(); + this._sendMouse(cursorPos.x, cursorPos.y, 0x1); + this._sendMouse(cursorPos.x, cursorPos.y, 0); + this._mouseButtonMask = 0; + } + _sendTouchpadTwoTap() { + this._clearTouchpadTapTimeoutId(); + const cursorPos = this._getCursorPositionToCanvas(); + this._sendMouse(cursorPos.x, cursorPos.y, 0x4); + this._sendMouse(cursorPos.x, cursorPos.y, 0); + this._mouseButtonMask = 0; + } + + _sendTouchpadThreeTap() { + this._clearTouchpadTapTimeoutId(); + const cursorPos = this._getCursorPositionToCanvas(); + this._sendMouse(cursorPos.x, cursorPos.y, 0x2); + this._sendMouse(cursorPos.x, cursorPos.y, 0); + this._mouseButtonMask = 0; + } + + /** + * Gets the current cursor position, offset by the canvas client bounds. + * @returns {{x: number, y: number}} + */ + _getCursorPositionToCanvas() { + const cursorPos = this._cursor.position; + return clientToElement(cursorPos.x, cursorPos.y, this._canvas); + } + + /** + * Returns the center area within the canvas bounds where + * cursor movement won't trigger viewport movement. + * @returns {{ + * top: number, + * bottom: number, + * left: number, + * right: number, + * width: number, + * height: number + * }} + */ + _getTouchpadCursorDeadZone() { + const canvasBounds = this._canvas.getBoundingClientRect(); + const canvasCenter = { + x: canvasBounds.width * .5, + y: canvasBounds.height * .5 + } + const xFromCenter = canvasBounds.width * .1; + const yFromCenter = canvasBounds.height * .1; + const innerWidth = xFromCenter * 2; + const innerHeight = yFromCenter * 2; + + return { + top: canvasCenter.y - yFromCenter, + bottom: canvasCenter.y + yFromCenter, + height: innerHeight, + left: canvasCenter.x - xFromCenter, + right: canvasCenter.x + xFromCenter, + width: innerWidth + } + } + // Message Handlers _negotiateProtocolVersion() { diff --git a/core/util/browser.js b/core/util/browser.js index bbc9f5c1e..1db35b5c8 100644 --- a/core/util/browser.js +++ b/core/util/browser.js @@ -11,17 +11,26 @@ import * as Log from './logging.js'; // Touch detection -export let isTouchDevice = ('ontouchstart' in document.documentElement) || - // requried for Chrome debugger - (document.ontouchstart !== undefined) || - // required for MS Surface - (navigator.maxTouchPoints > 0) || - (navigator.msMaxTouchPoints > 0); +let _touchEventOccurred = false; window.addEventListener('touchstart', function onFirstTouch() { - isTouchDevice = true; + _touchEventOccurred = true; window.removeEventListener('touchstart', onFirstTouch, false); }, false); +// This needs to be a function to allow the exported value +// to update if touchstart event fires. Also, the other +// values are dynamic and can change without a page reload +// (e.g. opening the emulator in dev tools), so we don't want +// to assign them to a variable that captures their current value. +export function isTouchDevice() { + return _touchEventOccurred || + ('ontouchstart' in document.documentElement) || + // requried for Chrome debugger + (document.ontouchstart !== undefined) || + // required for MS Surface + (navigator.maxTouchPoints > 0) || + (navigator.msMaxTouchPoints > 0); +}; // The goal is to find a certain physical width, the devicePixelRatio // brings us a bit closer but is not optimal. diff --git a/core/util/cursor.js b/core/util/cursor.js index 3000cf0e6..0f8f86136 100644 --- a/core/util/cursor.js +++ b/core/util/cursor.js @@ -6,7 +6,7 @@ import { supportsCursorURIs, isTouchDevice } from './browser.js'; -const useFallback = !supportsCursorURIs || isTouchDevice; +const useFallback = () => !supportsCursorURIs || isTouchDevice(); export default class Cursor { constructor() { @@ -14,7 +14,7 @@ export default class Cursor { this._canvas = document.createElement('canvas'); - if (useFallback) { + if (useFallback()) { this._canvas.style.position = 'fixed'; this._canvas.style.zIndex = '65535'; this._canvas.style.pointerEvents = 'none'; @@ -37,6 +37,13 @@ export default class Cursor { }; } + /** + * @returns {{ x: number, y: number }} + */ + get position() { + return this._position; + } + attach(target) { if (this._target) { this.detach(); @@ -44,7 +51,7 @@ export default class Cursor { this._target = target; - if (useFallback) { + if (useFallback()) { document.body.appendChild(this._canvas); const options = { capture: true, passive: true }; @@ -62,7 +69,7 @@ export default class Cursor { return; } - if (useFallback) { + if (useFallback()) { const options = { capture: true, passive: true }; this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options); this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options); @@ -95,7 +102,7 @@ export default class Cursor { ctx.clearRect(0, 0, w, h); ctx.putImageData(img, 0, 0); - if (useFallback) { + if (useFallback()) { this._updatePosition(); } else { let url = this._canvas.toDataURL(); @@ -116,7 +123,7 @@ export default class Cursor { // Mouse events might be emulated, this allows // moving the cursor in such cases move(clientX, clientY) { - if (!useFallback) { + if (!useFallback()) { return; } // clientX/clientY are relative the _visual viewport_, From 2c11e27a4a24dce5531846c589479826bfcd1a14 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Sun, 4 Feb 2024 11:32:57 -0800 Subject: [PATCH 4/5] Adjust cursor coordinates by hotspot. --- core/rfb.js | 11 +++++++++-- core/util/cursor.js | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core/rfb.js b/core/rfb.js index 5dc8e95b7..395159af8 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -1532,7 +1532,11 @@ export default class RFB extends EventTargetMixin { // Finally, translate the coordinates to those relative to the // canvas and send the pointer move event to the remote machine. const posFromCanvas = clientToElement(safeX, safeY, this._canvas); - this._sendMouse(posFromCanvas.x, posFromCanvas.y, this._mouseButtonMask); + const hotspot = this._cursor.hotspot; + this._sendMouse( + posFromCanvas.x + hotspot.x, + posFromCanvas.y + hotspot.y, + this._mouseButtonMask); } _handleTouchpadOneTapEvent() { @@ -1616,7 +1620,10 @@ export default class RFB extends EventTargetMixin { * @returns {{x: number, y: number}} */ _getCursorPositionToCanvas() { - const cursorPos = this._cursor.position; + const cursorPos = { + x: this._cursor.position.x + this._cursor.hotspot.x, + y: this._cursor.position.y + this._cursor.hotspot.y + }; return clientToElement(cursorPos.x, cursorPos.y, this._canvas); } diff --git a/core/util/cursor.js b/core/util/cursor.js index 0f8f86136..12429c5fc 100644 --- a/core/util/cursor.js +++ b/core/util/cursor.js @@ -43,6 +43,13 @@ export default class Cursor { get position() { return this._position; } + + /** + * @returns {{ x: number, y: number }} + */ + get hotspot() { + return this._hotSpot; + } attach(target) { if (this._target) { From 183a7bc6b5b57e548e100daaf6d7d4a4eca40cf7 Mon Sep 17 00:00:00 2001 From: Jared Goodwin Date: Sun, 4 Feb 2024 14:06:23 -0800 Subject: [PATCH 5/5] Fix linting errors. --- app/ui.js | 77 +++++++++++++++++------------------- core/display.js | 8 ++-- core/input/gesturehandler.js | 12 +++--- core/rfb.js | 66 ++++++++++++++----------------- core/util/browser.js | 2 +- core/util/cursor.js | 6 +-- 6 files changed, 81 insertions(+), 90 deletions(-) diff --git a/app/ui.js b/app/ui.js index 94ef2b548..a7d2848ea 100644 --- a/app/ui.js +++ b/app/ui.js @@ -465,10 +465,10 @@ const UI = { }, /** - * @param {string} text - * @param { "normal" | "info" | "warn" | "warning" | "error" } statusType - * @param {number} time - * @returns + * @param {string} text + * @param { "normal" | "info" | "warn" | "warning" | "error" } statusType + * @param {number} time + * @returns */ showStatus(text, statusType, time) { const statusElem = document.getElementById('noVNC_status'); @@ -1614,44 +1614,41 @@ const UI = { * TOUCHPAD * ------v------*/ - toggleTouchpadMode() { - if (!UI.rfb) return; - - UI.rfb.touchpadMode = !UI.rfb.touchpadMode; - WebUtil.writeSetting('touchpad_mode', UI.rfb.touchpadMode); - UI.updateTouchpadMode(); - UI.updateViewDrag(); - }, - - updateTouchpadMode() { - if (UI.rfb.touchpadMode) { - UI.rfb.dragViewport = false; - - UI.forceSetting('resize', 'off'); - UI.forceSetting('view_clip', true); - UI.forceSetting('show_dot', true); - - UI.rfb.clipViewport = true; - UI.rfb.scaleViewport = false; - UI.rfb.resizeSession = false; - UI.rfb.showDotCursor = true; - } - else { - UI.enableSetting('resize'); - UI.enableSetting('view_clip'); - UI.enableSetting('show_dot'); - } + toggleTouchpadMode() { + if (!UI.rfb) return; + + UI.rfb.touchpadMode = !UI.rfb.touchpadMode; + WebUtil.writeSetting('touchpad_mode', UI.rfb.touchpadMode); + UI.updateTouchpadMode(); + UI.updateViewDrag(); + }, - UI.updateViewDrag + updateTouchpadMode() { + if (UI.rfb.touchpadMode) { + UI.rfb.dragViewport = false; + + UI.forceSetting('resize', 'off'); + UI.forceSetting('view_clip', true); + UI.forceSetting('show_dot', true); + + UI.rfb.clipViewport = true; + UI.rfb.scaleViewport = false; + UI.rfb.resizeSession = false; + UI.rfb.showDotCursor = true; + } else { + UI.enableSetting('resize'); + UI.enableSetting('view_clip'); + UI.enableSetting('show_dot'); + } + + const touchpadButton = document.getElementById('noVNC_touchpad_button'); + if (UI.rfb.touchpadMode) { + touchpadButton.classList.add("noVNC_selected"); + } else { + touchpadButton.classList.remove("noVNC_selected"); + } + }, - const touchpadButton = document.getElementById('noVNC_touchpad_button'); - if (UI.rfb.touchpadMode) { - touchpadButton.classList.add("noVNC_selected"); - } else { - touchpadButton.classList.remove("noVNC_selected"); - } - }, - /* ------^------- * /TOUCHPAD * ============== diff --git a/core/display.js b/core/display.js index c8704ebc3..da1e3153e 100644 --- a/core/display.js +++ b/core/display.js @@ -96,8 +96,8 @@ export default class Display { /** * Attempt to move the viewport by the specified amounts * and returns the amount of actual position change. - * @param {number} moveByX - * @param {number} moveByY + * @param {number} moveByX + * @param {number} moveByY * @return {{ x: number, y: number }} */ viewportTryMoveBy(moveByX, moveByY) { @@ -105,7 +105,7 @@ export default class Display { return { x: 0, y: 0 - } + }; } const vpX = this._viewportLoc.x; @@ -116,7 +116,7 @@ export default class Display { return { x: this._viewportLoc.x - vpX, y: this._viewportLoc.y - vpY - } + }; } viewportChangePos(deltaX, deltaY) { diff --git a/core/input/gesturehandler.js b/core/input/gesturehandler.js index 73e681cc0..86eb6420f 100644 --- a/core/input/gesturehandler.js +++ b/core/input/gesturehandler.js @@ -61,7 +61,7 @@ export default class GestureHandler { } /** - * @param {boolean} enabled + * @param {boolean} enabled */ set touchpadMode(enabled) { this._touchpadMode = enabled; @@ -115,8 +115,8 @@ export default class GestureHandler { } /** - * - * @param {TouchEvent} e + * + * @param {TouchEvent} e */ _eventHandler(e) { let fn; @@ -179,7 +179,7 @@ export default class GestureHandler { movementY: 0, angle: 0, }); - + switch (this._tracked.length) { case 1: this._startLongpressTimeout(); @@ -199,7 +199,7 @@ export default class GestureHandler { } } - _touchMove(id, x, y) { + _touchMove(id, x, y) { let touch = this._tracked.find(t => t.id === id); // If this is an update for a touch we're not tracking, ignore it @@ -495,7 +495,7 @@ export default class GestureHandler { detail['clientX'] = pos.x; detail['clientY'] = pos.y; - if (this._touchpadMode && + if (this._touchpadMode && this._tracked.length === 1) { const touch = this._tracked[0]; diff --git a/core/rfb.js b/core/rfb.js index 395159af8..93e1a5bbb 100644 --- a/core/rfb.js +++ b/core/rfb.js @@ -188,7 +188,7 @@ export default class RFB extends EventTargetMixin { this._viewportHasMoved = false; this._accumulatedWheelDeltaX = 0; this._accumulatedWheelDeltaY = 0; - + // Gesture state this._gestureLastTapTime = null; this._gestureFirstDoubleTapEv = null; @@ -413,7 +413,7 @@ export default class RFB extends EventTargetMixin { this._sendEncodings(); } } - + /** * @returns {boolean} */ @@ -422,7 +422,7 @@ export default class RFB extends EventTargetMixin { } /** - * @param {boolean} enabled + * @param {boolean} enabled */ set touchpadMode(enabled) { if (!this._gestures) { @@ -556,7 +556,7 @@ export default class RFB extends EventTargetMixin { const container = document.getElementById('noVNC_container'); const containerBounds = container.getBoundingClientRect(); const x = containerBounds.left + (containerBounds.width * .5); - const y = containerBounds.top + (containerBounds.height * .5) + const y = containerBounds.top + (containerBounds.height * .5); this._cursor.move(x, y); const elementPos = clientToElement(x, y, this._canvas); @@ -1299,8 +1299,7 @@ export default class RFB extends EventTargetMixin { case 'onetap': if (this._touchpadMode) { this._handleTouchpadOneTapEvent(); - } - else { + } else { this._handleTapEvent(ev, 0x1); } break; @@ -1326,12 +1325,12 @@ export default class RFB extends EventTargetMixin { // sequence. if (this._touchpadMode) { if (this._touchpadTapTimeoutId > 0) { - this._clearTouchpadTapTimeoutId(); - this._isTouchpadDragging = true; - this._mouseButtonMask = 0x1; - const cursorPos = this._getCursorPositionToCanvas(); - this._sendMouse(cursorPos.x, cursorPos.y, 0x1); - } + this._clearTouchpadTapTimeoutId(); + this._isTouchpadDragging = true; + this._mouseButtonMask = 0x1; + const cursorPos = this._getCursorPositionToCanvas(); + this._sendMouse(cursorPos.x, cursorPos.y, 0x1); + } break; } this._fakeMouseMove(ev, pos.x, pos.y); @@ -1343,8 +1342,7 @@ export default class RFB extends EventTargetMixin { if (this._touchpadMode) { const cursorPos = this._getCursorPositionToCanvas(); this._handleMouseButton(cursorPos.x, cursorPos.y, true, 0x4); - } - else { + } else { this._fakeMouseMove(ev, pos.x, pos.y); this._handleMouseButton(pos.x, pos.y, true, 0x4); } @@ -1382,8 +1380,7 @@ export default class RFB extends EventTargetMixin { // current position, not to where the touch currently is. if (this._touchpadMode) { this._handleTouchpadMove(ev.detail.movementX, ev.detail.movementY); - } - else { + } else { this._fakeMouseMove(ev, pos.x, pos.y); } break; @@ -1427,7 +1424,7 @@ export default class RFB extends EventTargetMixin { // We don't know if the mouse was moved so we need to move it // every update. this._fakeMouseMove(ev, pos.x, pos.y); - + if (Math.abs(magnitude - this._gestureLastMagnitudeX) > GESTURE_ZOOMSENS) { this._handleKeyEvent(KeyTable.XK_Control_L, "ControlLeft", true); while ((magnitude - this._gestureLastMagnitudeX) > GESTURE_ZOOMSENS) { @@ -1472,8 +1469,7 @@ export default class RFB extends EventTargetMixin { if (this._touchpadMode) { const cursorPos = this._getCursorPositionToCanvas(); this._handleMouseButton(cursorPos.x, cursorPos.y, false, 0x4); - } - else { + } else { this._fakeMouseMove(ev, pos.x, pos.y); this._handleMouseButton(pos.x, pos.y, false, 0x4); } @@ -1484,10 +1480,10 @@ export default class RFB extends EventTargetMixin { } // TouchpadMode Private Methods - + /** - * @param {number} movementX - * @param {number} movementY + * @param {number} movementX + * @param {number} movementY */ _handleTouchpadMove(movementX, movementY) { @@ -1511,12 +1507,10 @@ export default class RFB extends EventTargetMixin { // See if the cursor has moved outside the center deadzone. const deadzone = this._getTouchpadCursorDeadZone(); - const moveViewportX = - Math.min(safeX - deadzone.left, 0) + + const moveViewportX = Math.min(safeX - deadzone.left, 0) + Math.max(safeX - deadzone.right, 0); - const moveViewportY = - Math.min(safeY - deadzone.top, 0) + + const moveViewportY = Math.min(safeY - deadzone.top, 0) + Math.max(safeY - deadzone.bottom, 0); // Try moving the viewport, getting the actual amount it moved. @@ -1534,8 +1528,8 @@ export default class RFB extends EventTargetMixin { const posFromCanvas = clientToElement(safeX, safeY, this._canvas); const hotspot = this._cursor.hotspot; this._sendMouse( - posFromCanvas.x + hotspot.x, - posFromCanvas.y + hotspot.y, + posFromCanvas.x + hotspot.x, + posFromCanvas.y + hotspot.y, this._mouseButtonMask); } @@ -1547,7 +1541,7 @@ export default class RFB extends EventTargetMixin { this._sendTouchpadTap(); return; } - + this._touchpadTapTimeoutId = window.setTimeout(() => { this._clearTouchpadTapTimeoutId(); this._sendTouchpadTap(); @@ -1556,8 +1550,8 @@ export default class RFB extends EventTargetMixin { } /** - * - * @param {number} magnitude + * + * @param {number} magnitude */ _handleTouchpadPinchZoom(magnitude) { if (this._lastTouchpadPinchMagnitude > 0) { @@ -1570,16 +1564,16 @@ export default class RFB extends EventTargetMixin { // Capture the current viewport size. const originalVpW = this._display.viewportLocation.w; const originalVpH = this._display.viewportLocation.h; - + // Change viewport size based on new scale. const newWidth = container.clientWidth * this._currentPinchScale; const newHeight = container.clientHeight * this._currentPinchScale; this._display.viewportChangeSize(newWidth, newHeight); - + // Apply scaling to CSS. const visualScale = container.clientWidth / newWidth; this._display.rescale(visualScale); - + // Adjust viewport location to keep it centered. const moveX = (originalVpW - this._display.viewportLocation.w) / 2; const moveY = (originalVpH - this._display.viewportLocation.h) / 2; @@ -1644,7 +1638,7 @@ export default class RFB extends EventTargetMixin { const canvasCenter = { x: canvasBounds.width * .5, y: canvasBounds.height * .5 - } + }; const xFromCenter = canvasBounds.width * .1; const yFromCenter = canvasBounds.height * .1; const innerWidth = xFromCenter * 2; @@ -1657,7 +1651,7 @@ export default class RFB extends EventTargetMixin { left: canvasCenter.x - xFromCenter, right: canvasCenter.x + xFromCenter, width: innerWidth - } + }; } // Message Handlers diff --git a/core/util/browser.js b/core/util/browser.js index 1db35b5c8..6ba667d10 100644 --- a/core/util/browser.js +++ b/core/util/browser.js @@ -30,7 +30,7 @@ export function isTouchDevice() { // required for MS Surface (navigator.maxTouchPoints > 0) || (navigator.msMaxTouchPoints > 0); -}; +} // The goal is to find a certain physical width, the devicePixelRatio // brings us a bit closer but is not optimal. diff --git a/core/util/cursor.js b/core/util/cursor.js index 12429c5fc..9f24dff10 100644 --- a/core/util/cursor.js +++ b/core/util/cursor.js @@ -44,13 +44,13 @@ export default class Cursor { return this._position; } - /** + /** * @returns {{ x: number, y: number }} */ - get hotspot() { + get hotspot() { return this._hotSpot; } - + attach(target) { if (this._target) { this.detach();