blob: 2698a25c0c4f1d5dc6e991faad716f6866ca7be6 [file] [log] [blame]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {DisposableStack} from './disposable_stack';
import {bindEventListener} from './dom_utils';
import {assertTrue} from './logging';
export interface TouchArgs {
clientX: number;
clientY: number;
deltaX: number;
deltaY: number;
}
interface TouchHandlerCallbacks {
onPinchZoom?: (args: TouchArgs) => void;
onPan?: (args: TouchArgs) => void;
onTapDown?: (args: TouchArgs) => void;
onTapMove?: (args: TouchArgs) => void;
onTapUp?: (args: TouchArgs) => void;
}
const PAN_TIMEOUT_MS = 200;
/**
* TouchscreenHandler handles 3 types of gestures using Touch APIs on devices
* that support touch. Note this has nothing to do with "touchpad" (that is
* handled using wheel event). This has to do with mouse-less devices.
* This class supports 3 types of gestures:
* 1. Two finger pinch zoom (it always takes the precedence).
* 2. Single finger tap.
* 3. Single finger mouse emulation.
*
* 2 and 3 are disamgiguated by time: a touch down followed by a quick move is
* interpreted like a pan. Instead a touch down followed by a PAN_TIMEOUT_MS
* pause is interpreted as a mouse-like event.
*/
export class TouchscreenHandler {
private readonly trash = new DisposableStack();
private callbacks: TouchHandlerCallbacks;
private tapStart?: Touch;
private tapStartTime = 0;
private tapGeneration = 0;
private lastTouch?: Touch;
private lastPinch?: Touch[];
private mode?: 'deciding' | 'pinch' | 'pan' | 'mouse_emulation';
constructor(target: HTMLElement, callbacks: TouchHandlerCallbacks) {
this.callbacks = callbacks;
this.trash.use(
bindEventListener(target, 'touchstart', this.onTouchStart.bind(this)),
);
this.trash.use(
bindEventListener(target, 'touchmove', this.onTouchMove.bind(this), {
passive: false,
}),
);
this.trash.use(
bindEventListener(target, 'touchend', this.onTouchEnd.bind(this)),
);
}
[Symbol.dispose](): void {
this.trash.dispose();
}
// NOTE: touch events can be overlapped. If we start touching with a single
// finger and then we see a second one (which is very common by accident
// because humans rarely manage to put both fingers on screen at the same
// time), we'll see something like:
// onTouchStart(1 touch)
// onTouchStart(2 touches)
// ...
// onTouchEnd(2 touches)
// onTouchEnd(1 touch)
// We handle this by using the following logic:
// - pan and mouse_emulation are mutually exclusive, and they are determined
// based on what you do within the first 200ms.
// - you can "upgrade" from a pan or mouse_emulation to pinch if you add a
// second finger.
// - However, once in pinch mode, you can'd downgrade.
private onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
this.mode = 'pinch';
return;
}
if (e.touches.length !== 1) return;
const t = e.touches[0];
this.tapStart = t;
this.tapStartTime = performance.now();
this.lastTouch = t;
this.mode = 'deciding';
const tapGeneration = ++this.tapGeneration;
self.setTimeout(() => {
if (this.tapGeneration === tapGeneration && this.mode === 'deciding') {
this.initiateMouseEmulation(t);
}
}, PAN_TIMEOUT_MS);
};
private initiateMouseEmulation(t: {clientX: number; clientY: number}) {
assertTrue(this.mode === 'deciding');
this.mode = 'mouse_emulation';
this.callbacks.onTapDown?.({
deltaX: 0,
deltaY: 0,
clientX: t.clientX,
clientY: t.clientY,
});
}
private onTouchMove = (e: TouchEvent) => {
if (e.touches.length < 1 || e.touches.length > 2 || !this.tapStart) {
return;
}
if (e.touches.length === 2) {
e.preventDefault(); // Disable browser panning.
if (this.mode === 'mouse_emulation' && this.tapStart !== undefined) {
this.callbacks.onTapUp?.({
deltaX: 0,
deltaY: 0,
...this.tapStart,
});
}
this.mode = 'pinch';
this.handlePinch(e);
return;
}
// When pinch-zooming we can get a mixture of interleaved touchmove(1) and
// touchmove(2) depending on how many fingers we started with. Suppress the
// former as it creates jittery vertical scrolling while panning.
if (this.mode === 'pinch') {
e.preventDefault();
return;
}
// Single touch case. Here we need to disambiguate between:
// 1. A pan, which is a tapdown followed by a rapid movement.
// 2. A mouse-like touchmouse_emulation event, which we trigger when the user
// taps down and then doesn't move for TAP_TIMEOUT_MS.
const t = e.touches[0];
const dXInit = t.clientX - this.tapStart.clientX;
const dYInit = t.clientY - this.tapStart.clientY;
const distSq = dXInit * dXInit + dYInit * dYInit;
this.lastTouch ||= this.tapStart;
const deltaX = this.lastTouch.clientX - t.clientX;
const deltaY = this.lastTouch.clientY - t.clientY;
const args = {
clientX: t.clientX,
clientY: t.clientY,
deltaX,
deltaY,
};
this.lastTouch = t;
const elapsed = performance.now() - this.tapStartTime;
if (this.mode === 'deciding') {
if (distSq > 25 && elapsed < PAN_TIMEOUT_MS) {
this.mode = 'pan';
} else if (elapsed >= PAN_TIMEOUT_MS) {
this.initiateMouseEmulation(this.tapStart);
} else {
return;
}
}
// Not an "else" because we want to fall through after we change the
// singleTapType above.
if (this.mode === 'mouse_emulation') {
e.preventDefault();
this.callbacks.onTapMove?.({...args});
} else if (this.mode === 'pan') {
this.callbacks.onPan?.({...args});
}
};
private onTouchEnd = (e: TouchEvent) => {
this.tapStart = undefined;
const mode = this.mode;
this.mode = undefined;
this.lastTouch = undefined;
this.lastPinch = undefined;
if (mode === 'mouse_emulation') {
const t = e.changedTouches[0];
this.callbacks.onTapUp?.({
deltaX: 0,
deltaY: 0,
clientX: t.clientX,
clientY: t.clientY,
});
}
};
private handlePinch(e: TouchEvent) {
if (e.touches.length !== 2) return;
if (this.lastPinch === undefined) {
this.lastPinch = [e.touches[0], e.touches[1]];
}
function distance(t: ArrayLike<Touch>) {
const dX = t[0].clientX - t[1].clientX;
const dY = t[0].clientY - t[1].clientY;
return Math.sqrt(dX * dX + dY * dY);
}
const delta = Math.round(distance(this.lastPinch) - distance(e.touches));
this.lastPinch = [e.touches[0], e.touches[1]];
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
this.callbacks.onPinchZoom?.({
clientX: Math.round(midX),
clientY: Math.round(midY),
deltaX: delta,
deltaY: delta,
});
}
}
export type TouchEventTranslation =
| 'down-up-move'
| 'pan-x'
| 'pinch-zoom-as-ctrl-wheel';
export function convertTouchIntoMouseEvents(
target: HTMLElement,
events: TouchEventTranslation[],
): Disposable {
return new TouchscreenHandler(target, {
onTapDown(args: TouchArgs) {
if (!events.includes('down-up-move')) return;
target.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
buttons: 1,
...args,
}),
);
},
onTapUp(args: TouchArgs) {
if (!events.includes('down-up-move')) return;
target.dispatchEvent(
new MouseEvent('mouseup', {
bubbles: true,
buttons: 0,
...args,
}),
);
},
onTapMove(args: TouchArgs) {
if (!events.includes('down-up-move')) return;
target.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
buttons: 1,
movementX: args.deltaX,
movementY: args.deltaY,
...args,
}),
);
},
onPan(args: TouchArgs) {
if (
Math.abs(args.deltaX) > Math.abs(args.deltaY) &&
events.includes('pan-x')
) {
withInertia(args.deltaX, 0, (dx) =>
target.dispatchEvent(
new WheelEvent('wheel', {
...args,
deltaX: dx,
deltaY: 0,
ctrlKey: false,
}),
),
);
}
},
onPinchZoom(args: TouchArgs) {
if (!events.includes('pinch-zoom-as-ctrl-wheel')) return;
// We translate pinch zoom into Ctrl+vertical wheel. This is consistent
// with what most laptops seem to do when pinching on the touchpad.
target.dispatchEvent(
new WheelEvent('wheel', {
...args,
deltaX: 0,
deltaY: args.deltaY,
ctrlKey: true,
}),
);
},
});
}
function withInertia(
vx: number,
vy: number,
callback: (dx: number, dy: number) => void,
options?: {
friction?: number;
minVelocity?: number;
},
): () => void {
const friction = options?.friction ?? 0.8;
const minVelocity = options?.minVelocity ?? 0.05;
let frame: number | null = null;
function step() {
vx *= friction;
vy *= friction;
if (Math.abs(vx) < minVelocity && Math.abs(vy) < minVelocity) {
if (frame !== null) cancelAnimationFrame(frame);
return;
}
callback(vx, vy);
frame = requestAnimationFrame(step);
}
frame = requestAnimationFrame(step);
return () => {
if (frame !== null) {
cancelAnimationFrame(frame);
frame = null;
}
};
}