chromium/ios/web/annotations/resources/text_click.ts

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

/**
 * @fileoverview Handle tap on 'CHROME_ANNOTATION' elements.
 */

import {TextDecoration} from '//ios/web/annotations/resources/text_decoration';
import {LiveTaskTimer, TaskTimer} from '//ios/web/annotations/resources/text_tasks.js';

// Consumer of CHROME_ANNOTATION `HTMLElement` taps.
type AnnotationsTapConsumer = {
  (element: HTMLElement, cancel: boolean): void;
};

// Delay while checking for DOM mutations.
const DOM_MUTATION_DELAY_MS = 300;

// Monitors DOM mutations between instance construction until a call to
// `stopObserving`.
class MutationsTracker {
  // Returns true if DOM mutations occurred.
  public hasMutations = false;

  private mutationObserver: MutationObserver;
  private mutationExtendId = 0;

  // Mutation observer for handling added and removed nodes. `strict` is true
  // during both phases of the event, and false during the extra monitoring
  // mutation delay afterward (in case the undetected 'button' caused some
  // delayed/network action that will cause a mutation very soon after the
  // click).
  private mutationCallback = (mutationList: MutationRecord[]) => {
    for (let mutation of mutationList) {
      if (this.strict ||
          mutation.target.contains(this.initialEvent.target as Node)) {
        this.hasMutations = true;
        this.mutationObserver?.disconnect();
        return;
      }
    }
  };

  // Constructs a new instance given an `initialEvent` and starts listening for
  // changes to the DOM. `initialEvent` is the click event on a
  // CHROME_ANNOTATION at the beginning of the capture phase; it is used in
  // `hasPreventativeActivity` ta make sure the bubbling event received is the
  // same. If not, it is considered, like mutations, as a preventative activity.
  constructor(
      private readonly initialEvent: Event, root: Element,
      private taskTimer: TaskTimer = new LiveTaskTimer(),
      private strict = true) {
    this.mutationObserver = new MutationObserver(this.mutationCallback);
    this.mutationObserver.observe(
        root, {attributes: false, childList: true, subtree: true});
  }

  // Returns true if event doesn't match the event passed at construction, or it
  // was prevented or if any DOM mutations occurred.
  hasPreventativeActivity(event: Event): boolean {
    return event !== this.initialEvent || event.defaultPrevented ||
        this.hasMutations;
  }

  // Extends DOM observation by triggering `then` after `delayMs`. This can
  // be called multiple times if needed.
  extendObservation(then: Function, delayMs: number): void {
    this.strict = false;
    if (this.mutationExtendId) {
      this.taskTimer.clear(this.mutationExtendId);
    }
    this.mutationExtendId = this.taskTimer.reset(then, delayMs);
  }

  stopObserving(): void {
    if (this.mutationExtendId) {
      this.taskTimer.clear(this.mutationExtendId);
    }
    this.mutationExtendId = 0;
    this.mutationObserver?.disconnect();
  }

  // Force updating before next js main thread cycle.
  updateForTesting(): void {
    this.mutationCallback(this.mutationObserver.takeRecords());
  }
}

// Class to monitor taps on CHROME_ANNOTATION elements.
class TextClick {
  private mutationObserver: MutationsTracker|null = null;

  constructor(
      private root: Element, private consumer: AnnotationsTapConsumer,
      private decorationsProvider: () => Map<number, TextDecoration>| undefined,
      private taskTimer: TaskTimer = new LiveTaskTimer(),
      private mutationCheckDelay = DOM_MUTATION_DELAY_MS,
      private annotationForTest: Element|null = null) {}

  // Starts event listeners.
  start(): void {
    // First check when capturing down event.
    this.root.addEventListener('click', this.onClick, {capture: true});
    // Last checks when bubbling up event.
    this.root.addEventListener('click', this.onClick);
  }

  // Stops event listeners.
  stop(): void {
    this.root.removeEventListener('click', this.onClick, {capture: true});
    this.root.removeEventListener('click', this.onClick);
    this.cancelObserver();
  }

  // Force updating before next js main thread cycle.
  updateForTesting(): void {
    this.mutationObserver?.updateForTesting();
  }

  // Force annotation for testing, because document.elementFromPoint doesn't
  // seem to work on test webview (visibility?).
  annotationForTesting(annotation: Element): void {
    this.annotationForTest = annotation;
  }

  // Callback for tap handler.
  private onClick = (event: Event) => {
    this.handleTopTap(event as PointerEvent);
  };

  // Stops observing DOM mutations.
  private cancelObserver(): void {
    this.mutationObserver?.stopObserving();
    this.mutationObserver = null;
  }

  // Sets all `pointerEvents` style in the decoration list to the given `value`.
  private toggleDecorationsPointerEvents(value: string): void {
    this.decorationsProvider()?.forEach((decoration) => {
      if (!decoration.live)
        return;
      decoration.replacements.forEach((replacement) => {
        if (replacement instanceof HTMLElement) {
          replacement.style.pointerEvents = value;
        }
      });
    });
  }

  // Monitors taps at the top, document level. This checks if it is tap
  // triggered by an annotation and if no DOM mutation have happened while the
  // event is bubbling up. If it's the case, the annotation callback is called.
  private handleTopTap(event: PointerEvent): void {
    // Make decoration not inert and find if the actual target should be an
    // annotation. This way CHROME_ANNOTATION are never target in an Event.
    let annotation = this.annotationForTest;
    if (!annotation) {
      this.toggleDecorationsPointerEvents('all');
      annotation = document.elementFromPoint(event.clientX, event.clientY);
      this.toggleDecorationsPointerEvents('none');
    }

    if (annotation instanceof HTMLElement &&
        annotation.tagName === 'CHROME_ANNOTATION') {
      if (event.eventPhase === Event.CAPTURING_PHASE) {
        // Initiates a `mutationObserver` that will be checked at bubble up
        // phase where it will be decided if the click should be cancelled.
        this.cancelObserver();
        this.mutationObserver =
            new MutationsTracker(event, this.root, this.taskTimer);
      } else if (this.mutationObserver) {
        // At BUBBLING_PHASE.
        if (this.mutationObserver.hasPreventativeActivity(event)) {
          this.consumer(annotation, /*cancel*/ true);
          this.cancelObserver();
        } else {
          this.mutationObserver.extendObservation(() => {
            if (this.mutationObserver) {
              this.consumer(annotation, this.mutationObserver.hasMutations);
              this.cancelObserver();
            }
          }, this.mutationCheckDelay);
        }
      }
    } else {
      this.cancelObserver();
    }
  }
}

export {
  DOM_MUTATION_DELAY_MS,
  AnnotationsTapConsumer,
  TextClick,
}