From aead4f16fea709d2287f5aa1bef47793b7555afb Mon Sep 17 00:00:00 2001 From: jotaen4tinypilot <83721279+jotaen4tinypilot@users.noreply.github.com> Date: Thu, 18 Apr 2024 18:12:47 +0200 Subject: [PATCH] Emulate double click from touch devices (#1787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves https://github.com/tiny-pilot/tinypilot/issues/1781. This PR improves double-click emulation so that both of the consecutive mouse (touch) events have the exact same location. On a touch device, you might otherwise accidentally direct the second click to a slightly different location, or the target operating system might not even recognise both clicks as proper double click. ## Notes - I’ve slightly re-ordered the commentary, which I thought was sensible due to the increased complexity in the file. - The parameters for detecting a double-click are heuristic at best – the 500ms delay is from [this article](https://en.wikipedia.org/wiki/Double-click#:~:text=rely%20upon%20it.-,Speed%20and%20timing,basis%20for%20other%20timed%20actions.), and the 500px distance just felt okay when trying from an iPad. I’ve tested with this on an iPad, so from my side a pure code review would suffice. Review
on CodeApprove --------- Co-authored-by: Jan Heuermann --- app/static/js/touch.js | 68 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/app/static/js/touch.js b/app/static/js/touch.js index a495617bb..56f3b6cea 100644 --- a/app/static/js/touch.js +++ b/app/static/js/touch.js @@ -5,11 +5,19 @@ * handling logic as good as possible, and to keep the complexity away from the * “regular” mouse handling code in the remote screen component. * - * We currently only provide rudimentary support for touch devices. So for now, - * this adapter is only capable of emulating single left clicks. + * We currently only provide basic support for touch devices. So for now, this + * adapter can emulate the following mouse actions: + * - Single left click + * - Double left click */ export class TouchToMouseAdapter { - _lastTouchPosition = { clientX: 0, clientY: 0 }; + constructor() { + this._lastTouchInfo = { + timestamp: new Date(), + clientX: 0, + clientY: 0, + }; + } /** * Synthetic mouse event that includes all properties that the @@ -24,15 +32,22 @@ export class TouchToMouseAdapter { * @returns {SyntheticMouseEvent} */ fromTouchStart(evt) { - // The corresponding `touchend` event won’t have the `touches` property - // set, so we need to preserve the latest one to be able to reconstruct the - // cursor position for the touch/mouse release. - const touch = evt.touches[0]; - this._lastTouchPosition = { - clientX: touch.clientX, - clientY: touch.clientY, + const touchInfo = { + timestamp: new Date(), + clientX: evt.touches[0].clientX, + clientY: evt.touches[0].clientY, }; - return mouseClickEvent(evt.target, this._lastTouchPosition, 1); + + // If this touch was a double click, use the mouse coordinates from the + // previous touch, so that the position is exactly the same. (See comment + // of `isDoubleClick` for why this is important.) + if (isDoubleClick(touchInfo, this._lastTouchInfo)) { + touchInfo.clientX = this._lastTouchInfo.clientX; + touchInfo.clientY = this._lastTouchInfo.clientY; + } + + this._lastTouchInfo = touchInfo; + return mouseClickEvent(evt.target, this._lastTouchInfo, 1); } /** @@ -43,7 +58,10 @@ export class TouchToMouseAdapter { * @returns {SyntheticMouseEvent} */ fromTouchEndOrCancel(evt) { - return mouseClickEvent(evt.target, this._lastTouchPosition, 0); + // A `touchend` or `touchcancel` event doesn’t have the touches attribute + // set, so we have to use the last known touch position instead, to keep + // the mouse cursor in the same position. + return mouseClickEvent(evt.target, this._lastTouchInfo, 0); } } @@ -57,3 +75,29 @@ function mouseClickEvent(target, touchPosition, buttons) { deltaY: 0, }; } + +/** + * Checks whether two consecutive touches are intended to be a double click + * (double tap). This is true if both touches occur within a short time span, + * and if their location is close to each other. Note that in contrast to a + * mouse device, tapping twice on a touch screen almost never yields the exact + * same position for both touches. In this case, the user might accidentally + * click in the wrong place, or the target operating system might not recognize + * the two clicks as proper double click. + */ +function isDoubleClick(touchInfo1, touchInfo2) { + return ( + distancePx(touchInfo1, touchInfo2) < 50 && + delayMs(touchInfo1.timestamp, touchInfo2.timestamp) < 500 + ); +} + +function distancePx(touchInfo1, touchInfo2) { + const a = Math.abs(touchInfo1.clientX - touchInfo2.clientX); + const b = Math.abs(touchInfo1.clientY - touchInfo2.clientY); + return Math.hypot(a, b); +} + +function delayMs(date1, date2) { + return Math.abs(date1.getTime() - date2.getTime()); +}