chromium/ui/file_manager/file_manager/foreground/js/ui/file_tap_handler.ts

// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * Processes touch events and calls back to the class user when tap events
 * defined by FileTapHandler.TapEvent are detected.
 *
 * The user can choose to 1) handle the tap event, in which case this class
 * will suppress browser mouse event generation, or 2) not handle the event
 * to let it be handled by mouse event handlers.
 */
export class FileTapHandler {
  /**
   * Whether the pointer is currently down and at the same place as the
   * initial position.
   */
  private tapStarted_ = false;
  private isLongTap_ = false;
  private isTwoFingerTap_ = false;
  private hasLongPressProcessed_ = false;
  private longTapDetectorTimerId_ = -1;
  /**
   * If defined, the identifier of the active touch. Note that 0 is a valid
   * touch identifier.
   */
  private activeTouchId_: number|undefined = undefined;

  /**
   * The index of the item which is being touched by the active touch. Valid
   * only when |activeTouchId_| is defined.
   */
  private activeItemIndex_ = -1;

  /**
   * Last touch X position in client co-ords.
   */
  private lastTouchX_ = 0;

  /**
   * Last touch Y position in client co-ords.
   */
  private lastTouchY_ = 0;

  /**
   * The absolute sum of all touch X deltas.
   */
  private totalMoveX_ = 0;

  /**
   * The absolute sum of all touch Y deltas.
   */
  private totalMoveY_ = 0;

  /**
   * Handles touch events. Calls touchend.preventDefault() if the |callback|
   * takes any action on the detected tap events to suppress the browser's
   * automatic conversion of touch events to mouse events:
   *
   *   browser events: touchstart > [touchmove] > touchend
   *    ... if touchend.preventDefault() not called ...
   *      browser events: mouseover > mousedown > [mousemove] > mouseup
   *
   * @param event Touch event.
   * @param index Index of the target item in the file list.
   * @param callback Called when a tap event is detected. Should return true if
   *     it has taken any action, and false if it ignores the event.
   * @return True if a tap event was detected and the |callback| processed the
   *     event. False otherwise.
   */
  handleTouchEvents(
      event: TouchEvent, index: number,
      callback:
          (event: TouchEvent, index: number, eventType: TapEvent) => boolean) {
    // If the event is not cancelable, touch scrolling is active. Reset the
    // touch tracking to disable tap event detection during scrolling.
    if (event.cancelable === false) {
      this.resetTouchTracking_();
      return false;
    }

    switch (event.type) {
      case 'touchcancel':
        this.resetTouchTracking_();
        break;

      case 'touchstart': {
        // Only track the position of the single touch. However, we detect a
        // two-finger tap for opening a context menu of the target.
        if (event.touches.length > 2) {
          this.tapStarted_ = false;
          return false;
        } else if (this.activeTouchId_ !== undefined) {
          this.isTwoFingerTap_ = event.touches.length === 2;
          return false;
        }

        this.resetTouchTracking_();
        const touch = event.targetTouches[0];
        this.activeTouchId_ = touch?.identifier;
        this.tapStarted_ = true;

        this.activeItemIndex_ = index;
        this.isLongTap_ = false;
        this.isTwoFingerTap_ = event.touches.length === 2;

        this.hasLongPressProcessed_ = false;
        this.longTapDetectorTimerId_ = setTimeout(() => {
          this.longTapDetectorTimerId_ = -1;
          if (!this.tapStarted_) {
            return;
          }
          this.isLongTap_ = true;
          if (callback(event, index, TapEvent.LONG_PRESS)) {
            this.hasLongPressProcessed_ = true;
          }
        }, LONG_PRESS_THRESHOLD_MILLISECONDS);

        this.lastTouchX_ = touch?.clientX ?? 0;
        this.lastTouchY_ = touch?.clientY ?? 0;
        this.totalMoveX_ = 0;
        this.totalMoveY_ = 0;
      } break;

      case 'touchmove': {
        const touch = this.findActiveTouch_(event.changedTouches);
        if (touch === undefined) {
          break;
        }

        if (!this.tapStarted_) {
          break;
        }

        this.totalMoveX_ += Math.abs(this.lastTouchX_ - touch.clientX);
        this.totalMoveY_ += Math.abs(this.lastTouchY_ - touch.clientY);

        // Allow some movement for two-finger taps, and none otherwise.
        let moveLimit = 0;
        if (this.isTwoFingerTap_) {
          moveLimit = MAX_TRACKING_FOR_TAP_;
        }

        // If the touch has moved outside limits, it's no longer a tap.
        if (this.totalMoveX_ > moveLimit || this.totalMoveY_ > moveLimit) {
          this.tapStarted_ = false;
        }

        this.lastTouchX_ = touch.clientX;
        this.lastTouchY_ = touch.clientY;
      } break;

      case 'touchend': {
        // Mark as no longer being touched.
        // Two-finger tap event is issued when either of the 2 touch points is
        // released. Stop tracking the tap to avoid issuing duplicate events.
        const tapStarted = this.resetTouchTracking_();

        if (!tapStarted) {
          break;
        }

        if (this.isLongTap_) {
          // The item at the touch start position is treated as the target item,
          // rather than the one at the touch end position. Note that |index| is
          // the latter.
          if (this.hasLongPressProcessed_ ||
              callback(event, this.activeItemIndex_, TapEvent.LONG_TAP)) {
            event.preventDefault();
            return true;
          }
        } else {
          // The item at the touch start position of the active touch is treated
          // as the target item. In case of the two-finger tap, the first touch
          // point points to the target.
          if (callback(
                  event, this.activeItemIndex_,
                  this.isTwoFingerTap_ ? TapEvent.TWO_FINGER_TAP :
                                         TapEvent.TAP)) {
            event.preventDefault();
            return true;
          }
        }
      } break;
    }

    return false;
  }

  /**
   * Resets the touch tracking state variables. Saves the |this.tapStarted_|
   * state first, then resets all tracking state variables.
   *
   * @return The saved |this.tapStarted_| state or false if there is no active
   *     touch Id.
   */
  private resetTouchTracking_(): boolean {
    const tapStarted = this.tapStarted_;
    this.tapStarted_ = false;

    const activeTouchId = this.activeTouchId_;
    this.activeTouchId_ = undefined;

    if (this.longTapDetectorTimerId_ !== -1) {
      clearTimeout(this.longTapDetectorTimerId_);
      this.longTapDetectorTimerId_ = -1;
    }

    if (activeTouchId !== undefined) {
      return tapStarted;
    }

    return false;
  }

  /**
   * Given a list of Touches, find the one matching the active touch Id. Note
   * Chrome currently always uses 0 as the Id, so we end up always choosing
   * the first element in the list.
   *
   * @param touches List of Touch objects to search.
   * @return Touch matching the active touch Id, or undefined if there is no
   *     active touch Id or no match was found.
   */
  private findActiveTouch_(touches: TouchList): Touch|undefined {
    if (this.activeTouchId_ !== undefined) {
      for (const touch of touches) {
        if (touch.identifier === this.activeTouchId_) {
          return touch;
        }
      }
    }
    return;
  }
}

/**
 * The minimum duration of a tap to be recognized as long press and long tap.
 * This should be consistent with the Views of Android.
 * https://android.googlesource.com/platform/frameworks/base/+/HEAD/core/java/android/view/ViewConfiguration.java
 * Also this should also be consistent with Chrome's behavior for issuing
 * drag-and-drop events by touchscreen.
 */
const LONG_PRESS_THRESHOLD_MILLISECONDS = 500;

/**
 * Maximum movement of touch required to be considered a tap.
 */
const MAX_TRACKING_FOR_TAP_ = 8;

export enum TapEvent {
  /**
  The touch started and ended quickly, aka, both events have triggered:
  touchstart and touchend.
  */
  TAP = 'tap',

  /**
  The touch started and took more than the threshold, it hasn't triggered
  the touchend yet, but the LONG_PRESS is processed.
  */
  LONG_PRESS = 'longpress',

  /**
  The touchstart and the touchend have triggered and took more than the
  threshold between the two.
  */
  LONG_TAP = 'longtap',

  /** Similart to TAP but with exactly 2 fingers. */
  TWO_FINGER_TAP = 'twofingertap',
}