// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {assert} from 'chrome://resources/js/assert.js';
import type {Point} from './constants.js';
export interface Gesture {
type: string;
detail: PinchEventDetail;
}
export interface PinchEventDetail {
center: Point;
direction?: string;
scaleRatio?: number|null;
startScaleRatio?: number|null;
}
// A class that listens for touch events and produces events when these
// touches form gestures (e.g. pinching).
export class GestureDetector {
private element_: HTMLElement;
private pinchStartEvent_: TouchEvent|null = null;
private lastTouchTouchesCount_: number = 0;
private lastEvent_: TouchEvent|null = null;
private isPresentationMode_: boolean = false;
/**
* The scale relative to the start of the pinch when handling ctrl-wheels.
* null when there is no ongoing pinch.
*/
private accumulatedWheelScale_: number|null = null;
/**
* A timeout ID from setTimeout used for sending the pinchend event when
* handling ctrl-wheels.
*/
private wheelEndTimeout_: number|null = null;
private eventTarget_: EventTarget = new EventTarget();
/** @param element The element to monitor for touch gestures. */
constructor(element: HTMLElement) {
this.element_ = element;
this.element_.addEventListener(
'touchstart', (this.onTouchStart_.bind(this) as (p1: Event) => any),
{passive: true});
const boundOnTouch = (this.onTouch_.bind(this) as (p1: Event) => any);
this.element_.addEventListener('touchmove', boundOnTouch, {passive: true});
this.element_.addEventListener('touchend', boundOnTouch, {passive: true});
this.element_.addEventListener(
'touchcancel', boundOnTouch, {passive: true});
this.element_.addEventListener(
'wheel', this.onWheel_.bind(this), {passive: false});
document.addEventListener(
'contextmenu', this.handleContextMenuEvent_.bind(this));
}
setPresentationMode(enabled: boolean) {
this.isPresentationMode_ = enabled;
}
getEventTarget(): EventTarget {
return this.eventTarget_;
}
/**
* Public for tests.
* @return True if the last touch start was a two finger touch.
*/
wasTwoFingerTouch(): boolean {
return this.lastTouchTouchesCount_ === 2;
}
/**
* Call the relevant listeners with the given |PinchEventDetail|.
* @param type The type of pinch event.
* @param detail The event to notify the listeners of.
*/
private notify_(type: string, detail: PinchEventDetail) {
// Adjust center into element-relative coordinates.
const clientRect = this.element_.getBoundingClientRect();
detail.center = {
x: detail.center.x - clientRect.x,
y: detail.center.y - clientRect.y,
};
this.eventTarget_.dispatchEvent(new CustomEvent(type, {detail}));
}
/** The callback for touchstart events on the element. */
private onTouchStart_(event: TouchEvent) {
this.lastTouchTouchesCount_ = event.touches.length;
if (!this.wasTwoFingerTouch()) {
return;
}
this.pinchStartEvent_ = event;
this.lastEvent_ = event;
this.notify_('pinchstart', {center: center(event)});
}
/** The callback for touch move, end, and cancel events on the element. */
private onTouch_(event: TouchEvent) {
if (!this.pinchStartEvent_) {
return;
}
const lastEvent = this.lastEvent_!;
// Check if the pinch ends with the current event.
if (event.touches.length < 2 ||
lastEvent.touches.length !== event.touches.length) {
const startScaleRatio = pinchScaleRatio(lastEvent, this.pinchStartEvent_);
this.pinchStartEvent_ = null;
this.lastEvent_ = null;
this.notify_(
'pinchend',
{startScaleRatio: startScaleRatio, center: center(lastEvent)});
return;
}
const scaleRatio = pinchScaleRatio(event, lastEvent);
const startScaleRatio = pinchScaleRatio(event, this.pinchStartEvent_);
this.notify_('pinchupdate', {
scaleRatio: scaleRatio,
// TODO(dhoss): Handle case where `scaleRatio` is null?
direction: scaleRatio! > 1.0 ? 'in' : 'out',
startScaleRatio: startScaleRatio,
center: center(event),
});
this.lastEvent_ = event;
}
/** The callback for wheel events on the element. */
private onWheel_(event: WheelEvent) {
// We handle ctrl-wheels to invoke our own pinch zoom. On Mac, synthetic
// ctrl-wheels are created from trackpad pinches. We handle these ourselves
// to prevent the browser's native pinch zoom. We also use our pinch
// zooming mechanism for handling non-synthetic ctrl-wheels. This allows us
// to anchor the zoom around the mouse position instead of the scroll
// position.
if (!event.ctrlKey) {
if (this.isPresentationMode_) {
this.notify_('wheel', {
center: {x: event.clientX, y: event.clientY},
direction: event.deltaY > 0 ? 'down' : 'up',
});
}
return;
}
event.preventDefault();
// Disable wheel gestures in Presentation mode.
if (this.isPresentationMode_) {
return;
}
const wheelScale = Math.exp(-event.deltaY / 100);
// Clamp scale changes from the wheel event as they can be
// quite dramatic for non-synthetic ctrl-wheels.
const scale = Math.min(1.25, Math.max(0.75, wheelScale));
const position = {x: event.clientX, y: event.clientY};
if (this.accumulatedWheelScale_ == null) {
this.accumulatedWheelScale_ = 1.0;
this.notify_('pinchstart', {center: position});
}
this.accumulatedWheelScale_ *= scale;
this.notify_('pinchupdate', {
scaleRatio: scale,
direction: scale > 1.0 ? 'in' : 'out',
startScaleRatio: this.accumulatedWheelScale_,
center: position,
});
// We don't get any phase information for the ctrl-wheels, so we don't know
// when the gesture ends. We'll just use a timeout to send the pinch end
// event a short time after the last ctrl-wheel we see.
if (this.wheelEndTimeout_ != null) {
window.clearTimeout(this.wheelEndTimeout_);
this.wheelEndTimeout_ = null;
}
const gestureEndDelayMs = 100;
const endEvent = {
startScaleRatio: this.accumulatedWheelScale_,
center: position,
};
this.wheelEndTimeout_ = window.setTimeout(() => {
this.notify_('pinchend', endEvent);
this.wheelEndTimeout_ = null;
this.accumulatedWheelScale_ = null;
}, gestureEndDelayMs);
}
private handleContextMenuEvent_(e: MouseEvent) {
// Stop Chrome from popping up the context menu on long press. We need to
// make sure the start event did not have 2 touches because we don't want
// to block two finger tap opening the context menu. We check for
// firesTouchEvents in order to not block the context menu on right click.
const capabilities = e.sourceCapabilities;
if (capabilities && capabilities.firesTouchEvents &&
!this.wasTwoFingerTouch()) {
e.preventDefault();
}
}
}
/**
* Computes the change in scale between this touch event and a previous one.
* @param event Latest touch event on the element.
* @param prevEvent A previous touch event on the element.
* @return The ratio of the scale of this event and the scale of the previous
* one.
*/
function pinchScaleRatio(event: TouchEvent, prevEvent: TouchEvent): number|
null {
const distance1 = distance(prevEvent);
const distance2 = distance(event);
return distance1 === 0 ? null : distance2 / distance1;
}
/**
* Computes the distance between fingers.
* @param event Touch event with at least 2 touch points.
* @return Distance between touch[0] and touch[1].
*/
function distance(event: TouchEvent): number {
assert(event.touches.length > 1);
const touch1 = event.touches[0]!;
const touch2 = event.touches[1]!;
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* Computes the midpoint between fingers.
* @param event Touch event with at least 2 touch points.
* @return Midpoint between touch[0] and touch[1].
*/
function center(event: TouchEvent): Point {
assert(event.touches.length > 1);
const touch1 = event.touches[0]!;
const touch2 = event.touches[1]!;
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2,
};
}