chromium/chrome/browser/resources/settings/search_settings.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.

// clang-format off
import {assert} from 'chrome://resources/js/assert.js';

import {loadTimeData} from './i18n_setup.js';

import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {createEmptySearchBubble, findAndRemoveHighlights, highlight, removeHighlights, stripDiacritics} from 'chrome://resources/js/search_highlight_utils.js';
import {DomIf, microTask} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {SettingsSectionElement} from './settings_page/settings_section.js';
import type {SettingsSubpageElement} from './settings_page/settings_subpage.js';

// clang-format on

/**
 * A data structure used by callers to combine the results of multiple search
 * requests.
 */
export interface SearchResult {
  canceled: boolean;
  didFindMatches: boolean;
  wasClearSearch: boolean;
}

/**
 * A CSS attribute indicating that a node should be ignored during searching.
 */
const SKIP_SEARCH_CSS_ATTRIBUTE: string = 'no-search';

/**
 * List of elements types that should not be searched at all.
 * The only DOM-MODULE node is in <body> which is not searched, therefore
 * DOM-MODULE is not needed in this set.
 */
const IGNORED_ELEMENTS: Set<string> = new Set([
  'CONTENT',
  'CR-ACTION-MENU',
  'CR-DIALOG',
  'CR-ICON-BUTTON',
  'CR-RIPPLE',
  'CR-SLIDER',
  'DIALOG',
  'IMG',
  'IRON-ICON',
  'IRON-LIST',
  'PAPER-RIPPLE',
  'PAPER-SPINNER-LITE',
  'SLOT',
  'STYLE',
  'TEMPLATE',
]);

/**
 * Traverses the entire DOM (including Shadow DOM), finds text nodes that
 * match the given regular expression and applies the highlight UI. It also
 * ensures that <settings-section> instances become visible if any matches
 * occurred under their subtree.
 *
 * @param root The root of the sub-tree to be searched
 * @return Whether or not matches were found.
 */
function findAndHighlightMatches(request: SearchRequest, root: Node): boolean {
  let foundMatches = false;
  const highlights: HTMLElement[] = [];

  // Returns true if the node or any of its ancestors are a settings-subpage.
  function isInSubpage(node: (Node|null)): boolean {
    while (node !== null) {
      if (node.nodeName === 'SETTINGS-SUBPAGE') {
        return true;
      }
      node = node instanceof ShadowRoot ? node.host : node.parentNode;
    }
    return false;
  }

  function doSearch(node: Node) {
    // NOTE: For subpage wrappers <template route-path="..."> when |no-search|
    // participates in a data binding:
    //
    //  - Always use noSearch Polymer property, for example
    //    no-search="[[foo]]"
    //  - *Don't* use a no-search CSS attribute like no-search$="[[foo]]"
    //
    // The latter throws an error during the automatic Polymer 2 conversion to
    // <dom-if><template...></dom-if> syntax.
    if (node.nodeName === 'DOM-IF' &&
        (node as DomIf).hasAttribute('route-path') && !(node as DomIf).if &&
        !(node as any)['noSearch'] &&
        !(node as DomIf).hasAttribute(SKIP_SEARCH_CSS_ATTRIBUTE)) {
      request.queue.addRenderTask(new RenderTask(request, node));
      return;
    }

    if (IGNORED_ELEMENTS.has(node.nodeName)) {
      return;
    }

    if (node instanceof HTMLElement) {
      const element = node as HTMLElement;
      if (element.hasAttribute(SKIP_SEARCH_CSS_ATTRIBUTE) ||
          element.hasAttribute('hidden') || element.style.display === 'none') {
        return;
      }
    }

    if (node.nodeType === Node.TEXT_NODE) {
      const textContent = node.nodeValue;
      if (textContent!.trim().length === 0) {
        return;
      }

      const strippedText = stripDiacritics(textContent!);
      const ranges = [];
      for (let match; match = request.regExp!.exec(strippedText);) {
        ranges.push({start: match.index, length: match[0].length});
      }

      if (ranges.length > 0) {
        foundMatches = true;
        revealParentSection(
            node, /*numResults=*/ ranges.length, request.bubbles);

        if (node.parentNode!.nodeName === 'OPTION') {
          const select = node.parentNode!.parentNode!;
          assert(select.nodeName === 'SELECT');

          // TODO(crbug.com/41096577): support showing bubbles inside subpages.
          // Currently, they're incorrectly positioned and there's no great
          // signal at which to know when to reposition them (because every
          // page asynchronously loads/renders things differently).
          if (isInSubpage(select)) {
            return;
          }

          showBubble(
              select, /*numResults=*/ ranges.length, request.bubbles,
              /*horizontallyCenter=*/ true);
        } else {
          request.addTextObserver(node);
          highlights.push(highlight(node, ranges));
        }
      }

      // Returning early since TEXT_NODE nodes never have children.
      return;
    }

    let child = node.firstChild;
    while (child !== null) {
      // Getting a reference to the |nextSibling| before calling doSearch()
      // because |child| could be removed from the DOM within doSearch().
      const nextSibling = child.nextSibling;
      doSearch(child);
      child = nextSibling;
    }

    const shadowRoot = (node as HTMLElement).shadowRoot;
    if (shadowRoot) {
      doSearch(shadowRoot);
    }
  }

  doSearch(root);
  request.addHighlights(highlights);
  return foundMatches;
}

/**
 * Finds and makes visible the <settings-section> parent of |node|.
 * @param bubbles A map of bubbles created so far.
 */
function revealParentSection(
    node: Node, numResults: number, bubbles: Map<Node, number>) {
  let associatedControl: HTMLElement|null = null;

  // Find corresponding SETTINGS-SECTION parent and make it visible.
  let parent = node;
  while (parent.nodeName !== 'SETTINGS-SECTION') {
    parent = parent.nodeType === Node.DOCUMENT_FRAGMENT_NODE ?
        (parent as ShadowRoot).host :
        parent.parentNode as Node;
    if (!parent) {
      // |node| wasn't inside a SETTINGS-SECTION.
      return;
    }
    if (parent.nodeName === 'SETTINGS-SUBPAGE') {
      const subpage = parent as SettingsSubpageElement;
      assert(
          subpage.associatedControl,
          'An associated control was expected for SETTINGS-SUBPAGE ' +
              subpage.pageTitle + ', but was not found.');
      associatedControl = subpage.associatedControl;
    }
  }
  (parent as SettingsSectionElement).hiddenBySearch = false;

  // Need to add the search bubble after the parent SETTINGS-SECTION has
  // become visible, otherwise |offsetWidth| returns zero.
  if (associatedControl) {
    showBubble(
        associatedControl, numResults, bubbles,
        /* horizontallyCenter= */ false);
  }
}

function showBubble(
    control: Node, numResults: number, bubbles: Map<Node, number>,
    horizontallyCenter: boolean) {
  const bubble = createEmptySearchBubble(control, horizontallyCenter);
  const numHits = numResults + (bubbles.get(bubble) || 0);
  bubbles.set(bubble, numHits);
  const msgName =
      numHits === 1 ? 'searchResultBubbleText' : 'searchResultsBubbleText';
  bubble.firstChild!.textContent = loadTimeData.getStringF(msgName, numHits);
}

abstract class Task {
  protected request: SearchRequest;
  protected node: Node;

  constructor(request: SearchRequest, node: Node) {
    this.request = request;
    this.node = node;
  }

  abstract exec(): Promise<void>;
}

/**
 * A task that takes a <template is="dom-if">...</template> node
 * corresponding to a setting subpage and renders it. A
 * SearchAndHighlightTask is posted for the newly rendered subtree, once
 * rendering is done.
 */
class RenderTask extends Task {
  declare protected node: DomIf;

  exec() {
    const routePath = this.node.getAttribute('route-path')!;

    const content = DomIf._contentForTemplate(
        this.node.firstElementChild as HTMLTemplateElement);
    const subpageTemplate = content!.querySelector('settings-subpage')!;
    subpageTemplate.setAttribute('route-path', routePath);
    assert(!this.node.if);
    this.node.if = true;

    return new Promise<void>(resolve => {
      const parent = this.node.parentNode!;
      microTask.run(() => {
        const renderedNode =
            parent.querySelector('[route-path="' + routePath + '"]');
        assert(renderedNode);
        // Register a SearchAndHighlightTask for the part of the DOM that was
        // just rendered.
        this.request.queue.addSearchAndHighlightTask(
            new SearchAndHighlightTask(this.request, renderedNode));
        resolve();
      });
    });
  }
}

class SearchAndHighlightTask extends Task {
  exec() {
    const foundMatches = findAndHighlightMatches(this.request, this.node);
    this.request.updateMatches(foundMatches);
    return Promise.resolve();
  }
}

class TopLevelSearchTask extends Task {
  declare protected node: HTMLElement;

  exec() {
    const shouldSearch = this.request.regExp !== null;
    this.setSectionsVisibility_(!shouldSearch);
    if (shouldSearch) {
      const foundMatches = findAndHighlightMatches(this.request, this.node);
      this.request.updateMatches(foundMatches);
    }

    return Promise.resolve();
  }

  private setSectionsVisibility_(visible: boolean) {
    const sections = this.node.querySelectorAll('settings-section');

    for (let i = 0; i < sections.length; i++) {
      sections[i].hiddenBySearch = !visible;
    }
  }
}

interface Queues {
  high: Task[];
  middle: Task[];
  low: Task[];
}

class TaskQueue {
  private request_: SearchRequest;
  private queues_: Queues;
  private running_: boolean;
  private onEmptyCallback_: (() => void)|null = null;

  constructor(request: SearchRequest) {
    this.request_ = request;

    this.reset();

    /**
     * Whether a task is currently running.
     */
    this.running_ = false;
  }

  /** Drops all tasks. */
  reset() {
    this.queues_ = {high: [], middle: [], low: []};
  }

  addTopLevelSearchTask(task: TopLevelSearchTask) {
    this.queues_.high.push(task);
    this.consumePending_();
  }

  addSearchAndHighlightTask(task: SearchAndHighlightTask) {
    this.queues_.middle.push(task);
    this.consumePending_();
  }

  addRenderTask(task: RenderTask) {
    this.queues_.low.push(task);
    this.consumePending_();
  }

  /**
   * Registers a callback to be called every time the queue becomes empty.
   */
  onEmpty(onEmptyCallback: () => void) {
    this.onEmptyCallback_ = onEmptyCallback;
  }

  private popNextTask_(): Task|undefined {
    return this.queues_.high.shift() || this.queues_.middle.shift() ||
        this.queues_.low.shift();
  }

  private consumePending_() {
    if (this.running_) {
      return;
    }

    const task = this.popNextTask_();
    if (!task) {
      this.running_ = false;
      if (this.onEmptyCallback_) {
        this.onEmptyCallback_();
      }
      return;
    }

    this.running_ = true;
    requestIdleCallback(() => {
      if (!this.request_.canceled) {
        task.exec().then(() => {
          this.running_ = false;
          this.consumePending_();
        });
      }
      // Nothing to do otherwise. Since the request corresponding to this
      // queue was canceled, the queue is disposed along with the request.
    });
  }
}

export class SearchRequest {
  private rawQuery_: string;
  private root_: Element;
  regExp: RegExp|null;
  canceled: boolean;
  private foundMatches_: boolean;
  resolver: PromiseResolver<SearchRequest>;
  queue: TaskQueue;
  private textObservers_: Set<MutationObserver>;
  private highlights_: HTMLElement[];
  bubbles: Map<HTMLElement, number>;

  constructor(rawQuery: string, root: Element) {
    this.rawQuery_ = rawQuery;
    this.root_ = root;
    this.regExp = this.generateRegExp_();

    /**
     * Whether this request was canceled before completing.
     */
    this.canceled = false;

    this.foundMatches_ = false;
    this.resolver = new PromiseResolver();

    this.queue = new TaskQueue(this);
    this.queue.onEmpty(() => {
      this.resolver.resolve(this);
    });

    this.textObservers_ = new Set();
    this.highlights_ = [];
    this.bubbles = new Map();
  }

  /** @param highlights The highlight wrappers to add */
  addHighlights(highlights: HTMLElement[]) {
    this.highlights_.push(...highlights);
  }

  removeAllTextObservers() {
    this.textObservers_.forEach(observer => {
      observer.disconnect();
    });
    this.textObservers_.clear();
  }

  removeAllHighlightsAndBubbles() {
    removeHighlights(this.highlights_);
    this.bubbles.forEach((_count, bubble) => bubble.remove());
    this.highlights_ = [];
    this.bubbles.clear();
  }

  addTextObserver(textNode: Node) {
    const originalParentNode = textNode.parentNode as Node;
    const observer = new MutationObserver(mutations => {
      const oldValue = mutations[0].oldValue!.trim();
      const newValue = textNode.nodeValue!.trim();
      if (oldValue !== newValue) {
        observer.disconnect();
        this.textObservers_.delete(observer);
        findAndRemoveHighlights(originalParentNode);
      }
    });
    observer.observe(
        textNode, {characterData: true, characterDataOldValue: true});
    this.textObservers_.add(observer);
  }

  /**
   * Fires this search request.
   */
  start() {
    this.queue.addTopLevelSearchTask(new TopLevelSearchTask(this, this.root_));
  }

  private generateRegExp_(): RegExp|null {
    let regExp = null;
    // Generate search text by escaping any characters that would be
    // problematic for regular expressions.
    const strippedQuery = stripDiacritics(this.rawQuery_.trim());
    const sanitizedQuery = strippedQuery.replace(SANITIZE_REGEX, '\\$&');
    if (sanitizedQuery.length > 0) {
      regExp = new RegExp(`(${sanitizedQuery})`, 'ig');
    }
    return regExp;
  }

  /**
   * @return Whether this SearchRequest refers to an identical query.
   */
  isSame(rawQuery: string): boolean {
    return this.rawQuery_ === rawQuery;
  }

  /**
   * Updates the result for this search request.
   */
  updateMatches(found: boolean) {
    this.foundMatches_ = this.foundMatches_ || found;
  }

  /** @return Whether any matches were found. */
  didFindMatches(): boolean {
    return this.foundMatches_;
  }
}

const SANITIZE_REGEX: RegExp = /[-[\]{}()*+?.,\\^$|#\s]/g;

export interface SearchManager {
  /**
   * @param text The text to search for.
   * @param page
   * @return A signal indicating that searching finished.
   */
  search(text: string, page: Element): Promise<SearchRequest>;
}

class SearchManagerImpl implements SearchManager {
  private activeRequests_: Set<SearchRequest> = new Set();
  private completedRequests_: Set<SearchRequest> = new Set();
  private lastSearchedText_: string|null = null;

  search(text: string, page: Element) {
    // Cancel any pending requests if a request with different text is
    // submitted.
    if (text !== this.lastSearchedText_) {
      this.activeRequests_.forEach(function(request) {
        request.removeAllTextObservers();
        request.removeAllHighlightsAndBubbles();
        request.canceled = true;
        request.resolver.resolve(request);
      });
      this.activeRequests_.clear();
      this.completedRequests_.forEach(request => {
        request.removeAllTextObservers();
        request.removeAllHighlightsAndBubbles();
      });
      this.completedRequests_.clear();
    }

    this.lastSearchedText_ = text;
    const request = new SearchRequest(text, page);
    this.activeRequests_.add(request);
    request.start();
    return request.resolver.promise.then(() => {
      this.activeRequests_.delete(request);
      this.completedRequests_.add(request);
      return request;
    });
  }
}

let instance: SearchManager|null = null;

export function getSearchManager(): SearchManager {
  if (instance === null) {
    instance = new SearchManagerImpl();
  }
  return instance;
}

/**
 * Sets the SearchManager singleton instance, useful for testing.
 */
export function setSearchManagerForTesting(searchManager: SearchManager) {
  instance = searchManager;
}