blob: 5c08d70f681abf29ffa8e27159c1d25c37057b01 [file] [log] [blame]
// Copyright 2016 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {NativeEventTarget as EventTarget} from 'chrome://resources/js/cr/event_target.m.js';
import {Point} from './constants.js';
/**
* @typedef {{
* center: !Point,
* direction: (string|undefined),
* scaleRatio: (?number|undefined),
* startScaleRatio: (?number|undefined),
* }}
*/
export let PinchEventDetail;
// A class that listens for touch events and produces events when these
// touches form gestures (e.g. pinching).
export class GestureDetector {
/**
* @param {!EventTarget|!Element} element The element to monitor for touch
* gestures.
*/
constructor(element) {
element.addEventListener(
'touchstart',
/** @type {function(!Event)} */ (this.onTouchStart_.bind(this)),
{passive: true});
const boundOnTouch =
/** @type {function(!Event)} */ (this.onTouch_.bind(this));
element.addEventListener('touchmove', boundOnTouch, {passive: true});
element.addEventListener('touchend', boundOnTouch, {passive: true});
element.addEventListener('touchcancel', boundOnTouch, {passive: true});
element.addEventListener(
'wheel',
/** @type {function(!Event)} */ (this.onWheel_.bind(this)),
{passive: false});
document.addEventListener(
'contextmenu', e => this.handleContextMenuEvent_(e));
this.pinchStartEvent_ = null;
this.lastTouchTouchesCount_ = 0;
/** @private {TouchEvent} */
this.lastEvent_ = null;
/**
* The scale relative to the start of the pinch when handling ctrl-wheels.
* null when there is no ongoing pinch.
*
* @private {?number}
*/
this.accumulatedWheelScale_ = null;
/**
* A timeout ID from setTimeout used for sending the pinchend event when
* handling ctrl-wheels.
*
* @private {?number}
*/
this.wheelEndTimeout_ = null;
/** @private {!EventTarget} */
this.eventTarget_ = new EventTarget();
}
/** @return {!EventTarget} */
getEventTarget() {
return this.eventTarget_;
}
/**
* Public for tests.
* @return {boolean} True if the last touch start was a two finger touch.
*/
wasTwoFingerTouch() {
return this.lastTouchTouchesCount_ === 2;
}
/**
* Call the relevant listeners with the given |PinchEventDetail|.
* @param {string} type The type of pinch event.
* @param {!PinchEventDetail} detail The event to notify the listeners of.
* @private
*/
notify_(type, detail) {
this.eventTarget_.dispatchEvent(new CustomEvent(type, {detail}));
}
/**
* The callback for touchstart events on the element.
* @param {!TouchEvent} event Touch event on the element.
* @private
*/
onTouchStart_(event) {
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.
* @param {!TouchEvent} event Touch event on the element.
* @private
*/
onTouch_(event) {
if (!this.pinchStartEvent_) {
return;
}
const lastEvent = /** @type {!TouchEvent} */ (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,
direction: scaleRatio > 1.0 ? 'in' : 'out',
startScaleRatio: startScaleRatio,
center: center(event),
});
this.lastEvent_ = event;
}
/**
* The callback for wheel events on the element.
* @param {!WheelEvent} event Wheel event on the element.
* @private
*/
onWheel_(event) {
// 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) {
return;
}
event.preventDefault();
// Disable wheel gestures in Presentation mode.
if (document.fullscreenElement !== null) {
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);
}
/**
* @param {!Event} e The context menu event
* @private
*/
handleContextMenuEvent_(e) {
// 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 =
/** @type {{ sourceCapabilities: Object }} */ (e).sourceCapabilities;
if (capabilities.firesTouchEvents && !this.wasTwoFingerTouch()) {
e.preventDefault();
}
}
}
/**
* Computes the change in scale between this touch event
* and a previous one.
* @param {!TouchEvent} event Latest touch event on the element.
* @param {!TouchEvent} prevEvent A previous touch event on the element.
* @return {?number} The ratio of the scale of this event and the
* scale of the previous one.
*/
function pinchScaleRatio(event, prevEvent) {
const distance1 = distance(prevEvent);
const distance2 = distance(event);
return distance1 === 0 ? null : distance2 / distance1;
}
/**
* Computes the distance between fingers.
* @param {!TouchEvent} event Touch event with at least 2 touch points.
* @return {number} Distance between touch[0] and touch[1].
*/
function distance(event) {
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 {!TouchEvent} event Touch event with at least 2 touch points.
* @return {!Point} Midpoint between touch[0] and touch[1].
*/
function center(event) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
};
}
// Export on |window| such that scripts injected from pdf_extension_test.cc can
// access it.
window.GestureDetector = GestureDetector;