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

import {TextAnnotationList, TextViewportAnnotation} from '//ios/web/annotations/resources/text_annotation_list.js';
import {TextClick} from '//ios/web/annotations/resources/text_click.js';
import {annotationExternalData, annotationFullText} from '//ios/web/annotations/resources/text_decoration.js';
import {TextDecorator} from '//ios/web/annotations/resources/text_decorator.js';
import {TextDOMObserver} from '//ios/web/annotations/resources/text_dom_observer.js';
import {getMetaContentByHttpEquiv, hasNoIntentDetection, HTMLElementWithSymbolIndex, NodeWithSymbolIndex, noFormatDetectionTypes, rectFromElement} from '//ios/web/annotations/resources/text_dom_utils.js';
import {TextChunk, TextExtractor} from '//ios/web/annotations/resources/text_extractor.js';
import {TextIntersectionObserver} from '//ios/web/annotations/resources/text_intersection_observer.js';
import {TextStyler} from '//ios/web/annotations/resources/text_styler.js';
import {IdleTaskTracker} from '//ios/web/annotations/resources/text_tasks.js';
import {gCrWeb} from '//ios/web/public/js_messaging/resources/gcrweb.js';
import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js';

// Used to trigger text extraction and decoration when system is idle.
let idleTaskTracker: IdleTaskTracker|null;
let intersectionObserver: TextIntersectionObserver|null;
let mutationObserver: TextDOMObserver|null;
let styler: TextStyler|null;
let decorator: TextDecorator|null;
let extractor: TextExtractor|null;
let click: TextClick|null;

// Mark: Private API

let uniqueId = 0;
const chunksInFlight = new Map<number, TextChunk>();

// Consumer of text `chunk` that, memorizes them and sends them to the browser
// side for intent detection.
function textChunkConsumer(chunk: TextChunk): void {
  chunksInFlight.set(++uniqueId, chunk);
  if (chunksInFlight.size === 1) {
    idleTaskTracker?.startActivityListeners();
  }
  let disabledTypes = noFormatDetectionTypes();
  sendWebKitMessage('annotations', {
    command: 'annotations.extractedText',
    text: chunk.text,
    seqId: uniqueId,
    metadata: {
      htmlLang: document.documentElement.lang,
      httpContentLanguage: getMetaContentByHttpEquiv('content-language'),
      wkNoTelephone: disabledTypes.has('telephone'),
      wkNoEmail: disabledTypes.has('email'),
      wkNoAddress: disabledTypes.has('address'),
      wkNoDate: disabledTypes.has('date'),
      wkNoUnit: disabledTypes.has('unit'),
    },
  });
}

// Creates a task to decorate the given `annotations` against the TextChunk with
// `seqId`. The text chunk is then deleted.
function decorateChunk(
    annotations: TextViewportAnnotation[], seqId: number): void {
  // Find and remove the TextChunk.
  const chunk = chunksInFlight.get(seqId);
  chunksInFlight.delete(seqId);

  idleTaskTracker?.schedule(() => {
    const run = new TextAnnotationList(
        annotations, chunk?.visibleStart, chunk?.visibleEnd);
    const count = annotations.length;
    if (decorator && chunk) {
      decorator.decorateChunk(chunk, run);
    } else {
      run.fail();
    }
    sendWebKitMessage('annotations', {
      command: 'annotations.decoratingComplete',
      annotations: count,
      successes: run.successes,
      failures: run.failures,
      cancelled: run.cancelled,
    });
  });

  if (chunksInFlight.size === 0) {
    idleTaskTracker?.stopActivityListeners();
  }
}

// Consumer of taps on annotations. Forwards to the browser side.
function tapConsumer(
    annotation: HTMLElementWithSymbolIndex, cancel: boolean): void {
  decorator?.highlightAnnotation(annotation);
  sendWebKitMessage('annotations', {
    command: 'annotations.onClick',
    cancel: cancel,
    data: annotation[annotationExternalData],
    rect: rectFromElement(annotation),
    text: annotation[annotationFullText],
  });
}

function decorationNodeRemovedConsumer(node: NodeWithSymbolIndex): void {
  decorator?.decorationNodeUnexpectedlyRemoved(node);
  // Clean cache of corrupted annotation on the browser side.
  sendWebKitMessage('annotations', {
    command: 'annotations.decoratingComplete',
    annotations: 0,
    successes: 0,
    failures: 1,
    cancelled: [node[annotationExternalData]],
  });
}

// Mark: Public API

// Starts the annotation observer.
function start(): void {
  // Check for already started or for a page request to not detect intent.
  if (hasNoIntentDetection() || intersectionObserver) {
    return;
  }
  const root = document.documentElement;
  idleTaskTracker = new IdleTaskTracker();
  click = new TextClick(root, tapConsumer, () => decorator?.decorations);
  extractor = new TextExtractor(textChunkConsumer);
  styler = new TextStyler();
  decorator = new TextDecorator(styler);
  intersectionObserver =
      new TextIntersectionObserver(root, extractor, idleTaskTracker);
  mutationObserver = new TextDOMObserver(
      root, intersectionObserver, decorationNodeRemovedConsumer);
  intersectionObserver.start();
  mutationObserver.start();
  click.start();
}

// Stops the annotation observer.
function stop(): void {
  click?.stop();
  idleTaskTracker?.shutdown();
  mutationObserver?.stop();
  intersectionObserver?.stop();
  decorator?.removeAllDecorations();
  styler = null;
  decorator = null;
  intersectionObserver = null;
  mutationObserver = null;
  extractor = null;
  idleTaskTracker = null;
  click = null;
}

// Triggers `decorator` to apply `annotations` on the cached text chunk for
// the given `seqId`.
function decorateAnnotations(
    annotations: TextViewportAnnotation[], seqId: number): void {
  decorateChunk(annotations, seqId);
}

function removeDecorations(): void {
  decorator?.removeAllDecorations();
}

function removeDecorationsWithType(type: string): void {
  decorator?.removeDecorationsOfType(type);
}

function removeHighlight(): void {
  decorator?.removeHighlight();
}

gCrWeb.annotations = {
  start,
  stop,
  decorateAnnotations,
  removeDecorations,
  removeDecorationsWithType,
  removeHighlight,
};