const HOLDING_THRESHOLD_MS = 300; const SCROLL_X_DAMPENING = 0.02; const SCROLL_Y_DAMPENING = 0.09; const MOVE_X_MULTIPLIER = 1.64; const MOVE_Y_MULTIPLIER = 1.64; class TouchpadController { constructor(connection) { this.currentMoveX = 0; this.currentMoveY = 0; this.lastMoveX = 0; this.lastMoveY = 0; this.shouldResetLastMove = false; this.isInHoldingMode = false; this.ongoingTouches = {}; this.holdModeTimeout = null; this.touchpadDiv = null; this.connection = connection; } getButtonCode(button) { let buttonCode = 0; if (button === "right") buttonCode = 1; return buttonCode; } // takes a gesture name and a touch list // adds the gesture name to the touch and returns false if that touch already had that gesture _gesture(gestureName, touchList) { for (let i = 0; i < touchList.length; i++) { const touch = this.ongoingTouches[touchList[i].identifier]; if (touch.gestured.includes(gestureName)) return false; touch.gestured.push(gestureName); } return true; } _sendRelativeMouseMovement(dx, dy) { if (dx === 0 && dy === 0) return false; this.connection.sendMessage("r", [dx, dy]); return true; } _sendRelativeMouseScroll(dx, dy) { if (dx === 0 && dy === 0) return false; this.connection.sendMessage("s", [dx, dy]); return true; } _sendMouseButtonDown(button="left") { this.connection.sendMessage("d", [this.getButtonCode(button)]); } _sendMouseButtonUp(button="left") { this.connection.sendMessage("u", [this.getButtonCode(button)]); } _sendSingleClick(button="left") { this.connection.sendMessage("c", [this.getButtonCode(button)]); } mountOn(element) { if (this.touchpadDiv) return; // Already mounted this.touchpadDiv = document.createElement("div"); this.touchpadDiv.classList.add("Touchpad"); this.touchpadDiv.addEventListener("touchmove", this.onTouchMove.bind(this)); this.touchpadDiv.addEventListener("touchend", this.onTouchEnd.bind(this)); this.touchpadDiv.addEventListener("touchstart", this.onTouchStart.bind(this)); element.appendChild(this.touchpadDiv); } unmount() { if (!this.touchpadDiv) return; // Not mounted - don't do anything this.touchpadDiv.removeEventListener("touchmove", this.onTouchMove.bind(this)); this.touchpadDiv.removeEventListener("touchend", this.onTouchEnd.bind(this)); this.touchpadDiv.removeEventListener("touchstart", this.onTouchStart.bind(this)); } onTouchMove(event) { const touches = event.changedTouches; const targetTouch = touches[0]; this.currentMoveX = targetTouch.pageX; this.currentMoveY = targetTouch.pageY; // When ending a touch and starting a new one in another part of the touchpad, // the cursor "rubber bands" to that position. // To solve this, the "last move" parameters are reset when a touch is ended // (see onTouchEnd()) if (this.shouldResetLastMove) { this.shouldResetLastMove = false; this.lastMoveX = this.currentMoveX; this.lastMoveY = this.currentMoveY; } const deltaX = this.currentMoveX - this.lastMoveX; const deltaY = this.currentMoveY - this.lastMoveY; this.lastMoveX = this.currentMoveX; this.lastMoveY = this.currentMoveY; // if two touches moved at the same time, assume scrolling intent // if _sendRelativeMouseScroll or _sendRelativeMouseMovement return true, it means that the delta values are non-zero and a packet has been sent to the server // in that case, the touches will be marked as moved let shouldMarkTouchesAsMoved = false; if (touches.length === 2) { if ( this._sendRelativeMouseScroll(deltaX * SCROLL_X_DAMPENING, deltaY * SCROLL_Y_DAMPENING) ) { shouldMarkTouchesAsMoved = true; } } else if (touches.length === 3 && this._gesture("GESTURE_RIGHT_CLICK", touches)) { shouldMarkTouchesAsMoved = true; this._sendSingleClick("right"); } else { if ( this._sendRelativeMouseMovement(deltaX * MOVE_X_MULTIPLIER, deltaY * MOVE_Y_MULTIPLIER) ) { shouldMarkTouchesAsMoved = true; } } if (shouldMarkTouchesAsMoved) { for (let i = 0; i < touches.length; i++) { const touch = this.ongoingTouches[touches[i].identifier]; touch.hasMoved = true; if (touch.indicatorElement) { touch.indicatorElement.style.top = `${targetTouch.pageY}px`; touch.indicatorElement.style.left = `${targetTouch.pageX}px`; } } } } onTouchEnd(event) { const changedTouches = event.changedTouches; this.shouldResetLastMove = true; if (changedTouches.length === 1) { // This is a single tap - left click if (!this.ongoingTouches[changedTouches[0].identifier].hasMoved) { this._sendSingleClick("left"); // We were in "holding mode" and now that touch event has ended, // thus we have to stop holding the left click button } else if (this.isInHoldingMode) { this._sendMouseButtonUp("left"); this.isInHoldingMode = false; } } // remove all ended touches for (let i = 0; i < changedTouches.length; i++) { const touch = changedTouches[i]; const knownTouch = this.ongoingTouches[changedTouches[0].identifier]; if (knownTouch && knownTouch.indicatorElement) { this.touchpadDiv.removeChild(knownTouch.indicatorElement); knownTouch.indicatorElement = null; delete knownTouch.indicatorElement; } this.ongoingTouches[touch.identifier] = null; delete this.ongoingTouches[touch.identifier]; } } onTouchStart(event) { const changedTouches = event.changedTouches; // Clear the hold mode time out if another touch begins if (this.holdModeTimeout) clearTimeout(this.holdModeTimeout); // If the touch is still unmoved and held for a certain amount of time, // we will enter "holding mode" which keeps the mouse button held while dragging // allowing you to, for example, select text or drag a scrollbar if (changedTouches.length === 1) { const targetTouch = changedTouches[0]; this.holdModeTimeout = setTimeout(() => { if (this.ongoingTouches[targetTouch.identifier] && !this.ongoingTouches[targetTouch.identifier].hasMoved) { this.isInHoldingMode = true; this._sendMouseButtonDown("left"); } }, HOLDING_THRESHOLD_MS); } for (let i = 0; i < changedTouches.length; i++) { const touch = changedTouches[i]; let indicatorElement = this.ongoingTouches[touch.identifier] ? this.ongoingTouches[touch.identifier].indicatorElement : null; if (!indicatorElement) { indicatorElement = document.createElement("div"); indicatorElement.classList.add("Touchpad-touch-indicator"); indicatorElement.style.top = `${touch.clientY}px`; indicatorElement.style.left = `${touch.clientX}px`; this.touchpadDiv.appendChild(indicatorElement); } this.ongoingTouches[touch.identifier] = { identifier: touch.identifier, clientX: touch.clientX, clientY: touch.clientY, hasMoved: false, gestured: [], indicatorElement }; } } } export default TouchpadController;