chromium/chrome/browser/resources/pdf/gesture_detector.ts

// 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,
  };
}