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.
---------
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());
+}