chromium/ios/web/annotations/resources/text_intersection_observer.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 Monitor and extract visible text on the page and pass it on to
 * the annotations manager.
 */

import {CountedIntersectionObserver} from '//ios/web/annotations/resources/text_dom_observer.js';
import {ElementWithSymbolIndex, HTMLElementWithSymbolIndex, isValidNode, NodeWithSymbolIndex} from '//ios/web/annotations/resources/text_dom_utils.js';
import {IdleTaskTracker} from '//ios/web/annotations/resources/text_tasks.js';

// Delay before starting text extraction.
const EXTRACTION_TIMEOUT_MS = 300;

// Using Symbol as property key ensure the data doesn't show up in
// property key lists.

// Tagged on an `Element` that is is visible according to IntersectionObserver.
const visibleElement = Symbol('visibleElement');

// Tagged on parent chain of every element with `visibleElement`. It maintains
// a count of how many descendants are visible and is used to avoid going
// down uselessly a branch of the DOM that is 100% not visible when extracting
// text.
const visibleDescendantCount = Symbol('visibleDescendantCount');

// Tagged on an text `Node` that contribute to their parent element
// `observedTextNodeCount`.
const observedNode = Symbol('observedNode');

// Tagged on an `Element` that is is visible according to IntersectionObserver.
// The attached value contains the number of text nodes child that have
// requested observation.
const observedTextNodeCount = Symbol('observedTextNodeCount');

// Interface to used parts of `IntersectionObserver`. Can be mocked easily.
class InternalIntersectionObserver {
  constructor(
      _callback: IntersectionObserverCallback,
      _options?: IntersectionObserverInit) {}
  disconnect(): void {}
  observe(_target: Element): void {}
  unobserve(_target: Element): void {}
}

// Real time `IntersectionObserverInterface` based on `IntersectionObserver`.
class LiveIntersectionObserver implements InternalIntersectionObserver {
  private observer: IntersectionObserver;

  constructor(
      callback: IntersectionObserverCallback,
      options?: IntersectionObserverInit) {
    this.observer = new IntersectionObserver(callback, options);
  }
  disconnect(): void {
    this.observer.disconnect();
  }
  observe(target: Element): void {
    this.observer.observe(target);
  }
  unobserve(target: Element): void {
    this.observer.unobserve(target);
  }
}

// Interface for objects wanting to visit the visible part of the DOM.
interface TextNodeVisitor {
  // Called when starting the visit.
  begin(): void;
  // Called for visible text `textNode` with `textContent` not null.
  visibleTextNode(textNode: Text): void;
  // Called for invisible `node` between `visibleTextNode`s.
  invisibleNode(node: Node): void;
  // Called before entering `node` subtree.
  enterVisibleNode(node: Node): void;
  // Called after leaving `node` subtree.
  leaveVisibleNode(node: Node): void;
  // Called when ending the visit.
  end(): void;
}

class TextIntersectionObserver implements CountedIntersectionObserver {
  private intersectionOptions = {
    // Monitor viewport.
    root: null,
    // Make intersect window bigger to prepare potential incoming (scrolling)
    // intents.
    rootMargin: '100px',
    // Catch any node partially in (extended) viewport.
    threshold: 0,
  };

  // IntersectionObserver can only observe `Element` objects, not `Node`s
  // like text nodes. To cope with that, we observe the text nodes parent, and
  // when they are called visible, we take for granted all their text nodes are
  // too (which is probably not true all the time, but there's no fast and
  // obvious solution).
  private observer: InternalIntersectionObserver|null = null;

  constructor(
      public root: Element, public visitor: TextNodeVisitor,
      private idleTaskTracker: IdleTaskTracker,
      private observerClass:
          typeof InternalIntersectionObserver = LiveIntersectionObserver,
      private visitAfterDelayMs = EXTRACTION_TIMEOUT_MS) {}

  // Cleanup visibility tags.
  private cleanup(): void {
    const traverseVisible = (node: NodeWithSymbolIndex) => {
      if (!isValidNode(node)) {
        return;
      }
      if (node instanceof Element && node.shadowRoot &&
          node.shadowRoot !== node as Node) {
        traverseVisible(node.shadowRoot);
      } else if (node.hasChildNodes()) {
        for (const childNode of node.childNodes as
             NodeListOf<NodeWithSymbolIndex>) {
          if (childNode[visibleDescendantCount] || childNode[visibleElement]) {
            traverseVisible(childNode);
          }
        }
        if (node[visibleElement] && node instanceof Element) {
          this.untagVisibleElement(node);
        }
      }
    };
    traverseVisible(this.root);
  }

  // Extracts visible text that hasn't been processed.
  // Releases intersection observation, so this will not trigger again and be
  // extracted only once. Unless node's text is mutated and the domObserver
  // re-adds it to the intersection observer.
  private visit(visitor: TextNodeVisitor): void {
    // DFS traversal to locate visible elements, in rendering order.
    const traverseVisible = (node: NodeWithSymbolIndex) => {
      if (!isValidNode(node)) {
        return;
      }
      if (node instanceof Element && node.shadowRoot &&
          node.shadowRoot !== node as Node) {
        traverseVisible(node.shadowRoot);
      } else if (node.hasChildNodes()) {
        const visible = node[visibleElement];
        for (const childNode of node.childNodes as
             NodeListOf<NodeWithSymbolIndex>) {
          if (visible && childNode.nodeType === Node.TEXT_NODE) {
            visitor.visibleTextNode(childNode as Text);
            this.unobserve(childNode);
          } else if (
              childNode[visibleDescendantCount] || childNode[visibleElement]) {
            visitor.enterVisibleNode(childNode);
            traverseVisible(childNode);
            visitor.leaveVisibleNode(childNode);
          } else {
            visitor.invisibleNode(childNode);
          }
        }
        if (visible && node instanceof Element) {
          this.untagVisibleElement(node);
        }
      }
    };
    visitor.begin();
    traverseVisible(this.root);
    visitor.end();
  }

  // Singleton function for text extraction. Needs to be a singleton for the
  // `idleTaskTracker` to replace an already scheduled extraction.
  private textExtractionTask = () => {
    this.visit(this.visitor);
  };

  // `IntersectionObserver` used to tag visibility of elements.
  private intersectionCallback: IntersectionObserverCallback = (entries) => {
    let updateNeeded = false;
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        this.tagVisibleElement(entry.target);
        updateNeeded = true;
      } else {
        this.untagVisibleElement(entry.target);
      }
    });
    if (updateNeeded) {
      this.idleTaskTracker.schedule(
          this.textExtractionTask, this.visitAfterDelayMs);
    }
  };

  // Tags given `element` with `visibleElement` symbol and updates the parent
  // chain of `visibleDescendantCount` tags.
  private tagVisibleElement(element: Element): void {
    let item: ElementWithSymbolIndex|null = element as ElementWithSymbolIndex;
    let parent: ElementWithSymbolIndex|null;
    if (item[visibleElement]) {
      return;
    }
    item[visibleElement] = true;
    while (item !== null && item !== this.root) {
      if (item instanceof ShadowRoot) {
        parent = item.host as ElementWithSymbolIndex;
      } else {
        parent = item.parentElement;
      }
      if (parent) {
        parent[visibleDescendantCount] =
            (parent[visibleDescendantCount] ?? 0) + 1;
      }
      item = parent;
    }
  }

  // Untags given `element` `visibleElement` symbol and updates the parent chain
  // of `visibleDescendantCount` tags.
  private untagVisibleElement(element: Element): void {
    let item: ElementWithSymbolIndex|null = element as ElementWithSymbolIndex;
    let parent: ElementWithSymbolIndex|null;
    if (!item[visibleElement]) {
      // It happens...
      return;
    }
    delete item[visibleElement];
    while (item !== null && item !== this.root) {
      if (item instanceof ShadowRoot) {
        parent = item.host as ElementWithSymbolIndex;
      } else {
        parent = item.parentElement;
      }
      if (parent) {
        if (parent[visibleDescendantCount] > 1) {
          parent[visibleDescendantCount] = parent[visibleDescendantCount] - 1;
        } else {
          delete parent[visibleDescendantCount];
        }
      }
      item = parent;
    }
  }

  // Mark: CountedIntersectionObserver

  observe(node: NodeWithSymbolIndex): void {
    // Already observed and counted.
    if (node[observedNode]) {
      return;
    }

    const element = node.parentElement as HTMLElementWithSymbolIndex;
    if (!element || !isValidNode(element)) {
      return;
    }

    node[observedNode] = true;

    let count = element[observedTextNodeCount] ?? 0;
    if (count === 0) {
      this.observer?.observe(element);
    }
    element[observedTextNodeCount] = count + 1;
  }

  unobserve(node: NodeWithSymbolIndex): void {
    // Not observed and counted.
    if (!node[observedNode]) {
      return;
    }

    delete node[observedNode];

    const element = node.parentElement as HTMLElementWithSymbolIndex;
    if (!element || !isValidNode(element)) {
      return;
    }
    let count = element[observedTextNodeCount] ?? 0;
    if (count === 1) {
      this.observer?.unobserve(element);
      delete element[observedTextNodeCount];
    } else {
      element[observedTextNodeCount] = count - 1;
    }
  }

  // Mark: Public API

  // Starts the intersection observer.
  start(): void {
    this.observer = new this.observerClass(
        this.intersectionCallback, this.intersectionOptions);
  }

  // Stops the intersection observer.
  stop(): void {
    this.cleanup();
    this.observer?.disconnect();
    this.observer = null;
  }
}

export {
  EXTRACTION_TIMEOUT_MS,
  visibleElement,
  visibleDescendantCount,
  observedNode,
  observedTextNodeCount,
  TextIntersectionObserver,
  TextNodeVisitor,
  InternalIntersectionObserver,
  LiveIntersectionObserver
}