tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

findInPage.js (26603B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
      3 * You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 /* import-globals-from extensionControlled.js */
      6 /* import-globals-from preferences.js */
      7 
      8 // A tweak to the standard <button> CE to use textContent on the <label>
      9 // inside the button, which allows the text to be highlighted when the user
     10 // is searching.
     11 
     12 /** @import MozInputSearch from "chrome://global/content/elements/moz-input-search.mjs" */
     13 
     14 const MozButtonClass = customElements.get("button");
     15 class HighlightableButton extends MozButtonClass {
     16  static get inheritedAttributes() {
     17    // @ts-expect-error super is MozButton from toolkit/content/widgets/button.js
     18    return Object.assign({}, super.inheritedAttributes, {
     19      ".button-text": "text=label,accesskey,crop",
     20    });
     21  }
     22 }
     23 customElements.define("highlightable-button", HighlightableButton, {
     24  extends: "button",
     25 });
     26 
     27 var gSearchResultsPane = {
     28  /** @type {string} */
     29  query: undefined,
     30  listSearchTooltips: new Set(),
     31  listSearchMenuitemIndicators: new Set(),
     32  /** @type {MozInputSearch} */
     33  searchInput: null,
     34  /** @type {HTMLDivElement} */
     35  searchTooltipContainer: null,
     36  // A map of DOM Elements to a string of keywords used in search
     37  // XXX: We should invalidate this cache on `intl:app-locales-changed`
     38  searchKeywords: new WeakMap(),
     39  inited: false,
     40 
     41  // A (node -> boolean) map of subitems to be made visible or hidden.
     42  subItems: new Map(),
     43 
     44  searchResultsHighlighted: false,
     45 
     46  searchableNodes: new Set([
     47    "button",
     48    "label",
     49    "description",
     50    "menulist",
     51    "menuitem",
     52    "checkbox",
     53  ]),
     54 
     55  init() {
     56    if (this.inited) {
     57      return;
     58    }
     59    this.inited = true;
     60    this.searchInput = /** @type {MozInputSearch} */ (
     61      document.getElementById("searchInput")
     62    );
     63    this.searchTooltipContainer = /** @type {HTMLDivElement} */ (
     64      document.getElementById("search-tooltip-container")
     65    );
     66 
     67    window.addEventListener("resize", () => {
     68      this._recomputeTooltipPositions();
     69    });
     70 
     71    if (!this.searchInput.hidden) {
     72      this.searchInput.addEventListener("input", this);
     73      window.addEventListener("DOMContentLoaded", () => {
     74        this.searchInput.updateComplete.then(() => {
     75          this.searchInput.focus();
     76        });
     77        // Initialize other panes in an idle callback.
     78        window.requestIdleCallback(() => this.initializeCategories());
     79      });
     80    }
     81    ensureScrollPadding();
     82  },
     83 
     84  /** @param {InputEvent} event */
     85  async handleEvent(event) {
     86    // Ensure categories are initialized if idle callback didn't run sooo enough.
     87    await this.initializeCategories();
     88    this.searchFunction(event);
     89  },
     90 
     91  /**
     92   * This stops the search input from moving, when typing in it
     93   * changes which items in the prefs are visible.
     94   */
     95  fixInputPosition() {
     96    let innerContainer = document.querySelector(".sticky-inner-container");
     97    let width =
     98      window.windowUtils.getBoundsWithoutFlushing(innerContainer).width;
     99    innerContainer.style.maxWidth = width + "px";
    100  },
    101 
    102  /**
    103   * Check that the text content contains the query string.
    104   *
    105   * @param String content
    106   *    the text content to be searched
    107   * @param String query
    108   *    the query string
    109   * @returns boolean
    110   *    true when the text content contains the query string else false
    111   */
    112  queryMatchesContent(content, query) {
    113    if (!content || !query) {
    114      return false;
    115    }
    116    return content.toLowerCase().includes(query.toLowerCase());
    117  },
    118 
    119  categoriesInitialized: false,
    120 
    121  /**
    122   * Will attempt to initialize all uninitialized categories
    123   */
    124  async initializeCategories() {
    125    //  Initializing all the JS for all the tabs
    126    if (!this.categoriesInitialized) {
    127      this.categoriesInitialized = true;
    128      // Each element of gCategoryInits is a name
    129      for (let category of gCategoryInits.values()) {
    130        category.init();
    131      }
    132      if (document.hasPendingL10nMutations) {
    133        await new Promise(r =>
    134          document.addEventListener("L10nMutationsFinished", r, { once: true })
    135        );
    136      }
    137    }
    138  },
    139 
    140  /**
    141   * Finds and returns text nodes within node and all descendants.
    142   * Iterates through all the siblings of the node object and adds each sibling to an
    143   * array if it's a TEXT_NODE, and otherwise recurses to check text nodes within it.
    144   * Source - http://stackoverflow.com/questions/10730309/find-all-text-nodes-in-html-page
    145   *
    146   * @param Node nodeObject
    147   *    DOM element
    148   * @returns array of text nodes
    149   */
    150  textNodeDescendants(node) {
    151    if (!node) {
    152      return [];
    153    }
    154    let all = [];
    155    for (node = node.firstChild; node; node = node.nextSibling) {
    156      if (node.nodeType === node.TEXT_NODE) {
    157        all.push(node);
    158      } else {
    159        all = all.concat(this.textNodeDescendants(node));
    160      }
    161    }
    162    return all;
    163  },
    164 
    165  /**
    166   * This function is used to find words contained within the text nodes.
    167   * We pass in the textNodes because they contain the text to be highlighted.
    168   * We pass in the nodeSizes to tell exactly where highlighting need be done.
    169   * When creating the range for highlighting, if the nodes are section is split
    170   * by an access key, it is important to have the size of each of the nodes summed.
    171   *
    172   * @param Array textNodes
    173   *    List of DOM elements
    174   * @param Array nodeSizes
    175   *    Running size of text nodes. This will contain the same number of elements as textNodes.
    176   *    The first element is the size of first textNode element.
    177   *    For any nodes after, they will contain the summation of the nodes thus far in the array.
    178   *    Example:
    179   *    textNodes = [[This is ], [a], [n example]]
    180   *    nodeSizes = [[8], [9], [18]]
    181   *    This is used to determine the offset when highlighting
    182   * @param String textSearch
    183   *    Concatination of textNodes's text content
    184   *    Example:
    185   *    textNodes = [[This is ], [a], [n example]]
    186   *    nodeSizes = "This is an example"
    187   *    This is used when executing the regular expression
    188   * @param String searchPhrase
    189   *    word or words to search for
    190   * @returns boolean
    191   *      Returns true when atleast one instance of search phrase is found, otherwise false
    192   */
    193  highlightMatches(textNodes, nodeSizes, textSearch, searchPhrase) {
    194    if (!searchPhrase) {
    195      return false;
    196    }
    197 
    198    let indices = [];
    199    let i = -1;
    200    while ((i = textSearch.indexOf(searchPhrase, i + 1)) >= 0) {
    201      indices.push(i);
    202    }
    203 
    204    // Looping through each spot the searchPhrase is found in the concatenated string
    205    for (let startValue of indices) {
    206      let endValue = startValue + searchPhrase.length;
    207      let startNode = null;
    208      let endNode = null;
    209      let nodeStartIndex = null;
    210 
    211      // Determining the start and end node to highlight from
    212      for (let index = 0; index < nodeSizes.length; index++) {
    213        let lengthNodes = nodeSizes[index];
    214        // Determining the start node
    215        if (!startNode && lengthNodes >= startValue) {
    216          startNode = textNodes[index];
    217          nodeStartIndex = index;
    218          // Calculating the offset when found query is not in the first node
    219          if (index > 0) {
    220            startValue -= nodeSizes[index - 1];
    221          }
    222        }
    223        // Determining the end node
    224        if (!endNode && lengthNodes >= endValue) {
    225          endNode = textNodes[index];
    226          // Calculating the offset when endNode is different from startNode
    227          // or when endNode is not the first node
    228          if (index != nodeStartIndex || index > 0) {
    229            endValue -= nodeSizes[index - 1];
    230          }
    231        }
    232      }
    233      let range = document.createRange();
    234      range.setStart(startNode, startValue);
    235      range.setEnd(endNode, endValue);
    236      this.getFindSelection(startNode.ownerGlobal).addRange(range);
    237 
    238      this.searchResultsHighlighted = true;
    239    }
    240 
    241    return !!indices.length;
    242  },
    243 
    244  /**
    245   * Get the selection instance from given window
    246   *
    247   * @param Object win
    248   *   The window object points to frame's window
    249   */
    250  getFindSelection(win) {
    251    // Yuck. See bug 138068.
    252    let docShell = win.docShell;
    253 
    254    let controller = docShell
    255      .QueryInterface(Ci.nsIInterfaceRequestor)
    256      .getInterface(Ci.nsISelectionDisplay)
    257      .QueryInterface(Ci.nsISelectionController);
    258 
    259    let selection = controller.getSelection(
    260      Ci.nsISelectionController.SELECTION_FIND
    261    );
    262    selection.setColors("currentColor", "#ffe900", "currentColor", "#003eaa");
    263 
    264    return selection;
    265  },
    266 
    267  /**
    268   * Shows or hides content according to search input
    269   *
    270   * @param String event
    271   *    to search for filted query in
    272   */
    273  async searchFunction(event) {
    274    let query = event.target.value.trim().toLowerCase();
    275    if (this.query == query) {
    276      return;
    277    }
    278 
    279    let firstQuery = !this.query && query;
    280    let endQuery = !query && this.query;
    281    let subQuery = this.query && query.includes(this.query);
    282    this.query = query;
    283 
    284    // If there is a query, don't reshow the existing hidden subitems yet
    285    // to avoid them flickering into view only to be hidden again by
    286    // this next search.
    287    this.removeAllSearchIndicators(window, !query.length);
    288 
    289    let srHeader = document.getElementById("header-searchResults");
    290    let noResultsEl = document.getElementById("no-results-message");
    291    if (this.query) {
    292      // If this is the first query, fix the search input in place.
    293      if (firstQuery) {
    294        this.fixInputPosition();
    295      }
    296      // Showing the Search Results Tag
    297      await gotoPref("paneSearchResults");
    298      srHeader.hidden = false;
    299 
    300      let resultsFound = false;
    301 
    302      // Building the range for highlighted areas
    303      let rootPreferencesChildren = [
    304        ...document.querySelectorAll(
    305          "#mainPrefPane > *:not([data-hidden-from-search], script, stringbundle)"
    306        ),
    307      ];
    308 
    309      if (subQuery) {
    310        // Since the previous query is a subset of the current query,
    311        // there is no need to check elements that is hidden already.
    312        rootPreferencesChildren = rootPreferencesChildren.filter(
    313          el => !el.hidden
    314        );
    315      }
    316 
    317      // Attach the bindings for all children if they were not already visible.
    318      for (let child of rootPreferencesChildren) {
    319        if (child.hidden) {
    320          child.classList.add("visually-hidden");
    321          child.hidden = false;
    322        }
    323      }
    324 
    325      let ts = performance.now();
    326      let FRAME_THRESHOLD = 1000 / 60;
    327 
    328      // Showing or Hiding specific section depending on if words in query are found
    329      for (let child of rootPreferencesChildren) {
    330        if (performance.now() - ts > FRAME_THRESHOLD) {
    331          // Creating tooltips for all the instances found
    332          for (let anchorNode of this.listSearchTooltips) {
    333            this.createSearchTooltip(anchorNode, this.query);
    334          }
    335          ts = await new Promise(resolve =>
    336            window.requestAnimationFrame(resolve)
    337          );
    338          if (query !== this.query) {
    339            return;
    340          }
    341        }
    342 
    343        if (
    344          !child.classList.contains("header") &&
    345          !child.classList.contains("subcategory") &&
    346          (await this.searchWithinNode(child, this.query))
    347        ) {
    348          child.classList.remove("visually-hidden");
    349 
    350          // Show the preceding search-header if one exists.
    351          let groupbox =
    352            child.closest("groupbox") || child.closest("[data-category]");
    353          let groupHeader =
    354            groupbox && groupbox.querySelector(".search-header");
    355          if (groupHeader) {
    356            groupHeader.hidden = false;
    357          }
    358 
    359          resultsFound = true;
    360        } else {
    361          child.classList.add("visually-hidden");
    362        }
    363      }
    364 
    365      // Hide any subitems that don't match the search term and show
    366      // only those that do.
    367      if (this.subItems.size) {
    368        for (let [subItem, matches] of this.subItems) {
    369          subItem.classList.toggle("visually-hidden", !matches);
    370        }
    371      }
    372 
    373      noResultsEl.hidden = !!resultsFound;
    374      noResultsEl.setAttribute("query", this.query);
    375      // XXX: This is potentially racy in case where Fluent retranslates the
    376      // message and ereases the query within.
    377      // The feature is not yet supported, but we should fix for it before
    378      // we enable it. See bug 1446389 for details.
    379      let msgQueryElem = document.getElementById("sorry-message-query");
    380      msgQueryElem.textContent = this.query;
    381      if (resultsFound) {
    382        // Creating tooltips for all the instances found
    383        for (let anchorNode of this.listSearchTooltips) {
    384          this.createSearchTooltip(anchorNode, this.query);
    385        }
    386      }
    387    } else {
    388      if (endQuery) {
    389        document
    390          .querySelector(".sticky-inner-container")
    391          .style.removeProperty("max-width");
    392      }
    393      noResultsEl.hidden = true;
    394      document.getElementById("sorry-message-query").textContent = "";
    395      // Going back to General when cleared
    396      await gotoPref("paneGeneral");
    397      srHeader.hidden = true;
    398 
    399      // Hide some special second level headers in normal view
    400      for (let element of document.querySelectorAll(".search-header")) {
    401        element.hidden = true;
    402      }
    403    }
    404 
    405    window.dispatchEvent(
    406      new CustomEvent("PreferencesSearchCompleted", { detail: query })
    407    );
    408  },
    409 
    410  /**
    411   * Determine if the given element is an anchor tag.
    412   *
    413   * @param {HTMLElement} el The element.
    414   * @returns {boolean} Whether or not the element is an anchor tag.
    415   */
    416  _isAnchor(el) {
    417    return (el.prefix === null || el.prefix === "html") && el.localName === "a";
    418  },
    419 
    420  /**
    421   * Finding leaf nodes and checking their content for words to search,
    422   * It is a recursive function
    423   *
    424   * @param Node nodeObject
    425   *    DOM Element
    426   * @param String searchPhrase
    427   * @returns boolean
    428   *    Returns true when found in at least one childNode, false otherwise
    429   */
    430  async searchWithinNode(nodeObject, searchPhrase) {
    431    let matchesFound = false;
    432    if (
    433      nodeObject.childElementCount == 0 ||
    434      (typeof nodeObject.children !== "undefined" &&
    435        Array.prototype.every.call(nodeObject.children, this._isAnchor)) ||
    436      this.searchableNodes.has(nodeObject.localName) ||
    437      (nodeObject.localName?.startsWith("moz-") &&
    438        nodeObject.localName !== "moz-input-box")
    439    ) {
    440      let simpleTextNodes = this.textNodeDescendants(nodeObject);
    441      if (nodeObject.shadowRoot) {
    442        simpleTextNodes.push(
    443          ...this.textNodeDescendants(nodeObject.shadowRoot)
    444        );
    445      }
    446      for (let node of simpleTextNodes) {
    447        let result = this.highlightMatches(
    448          [node],
    449          [node.length],
    450          node.textContent.toLowerCase(),
    451          searchPhrase
    452        );
    453        matchesFound = matchesFound || result;
    454      }
    455 
    456      // Collecting data from anonymous content / label / description
    457      let nodeSizes = [];
    458      let allNodeText = "";
    459      let runningSize = 0;
    460 
    461      let accessKeyTextNodes = [];
    462 
    463      if (
    464        nodeObject.localName == "label" ||
    465        nodeObject.localName == "description" ||
    466        nodeObject.localName.startsWith("moz-")
    467      ) {
    468        accessKeyTextNodes.push(...simpleTextNodes);
    469      }
    470 
    471      for (let node of accessKeyTextNodes) {
    472        runningSize += node.textContent.length;
    473        allNodeText += node.textContent;
    474        nodeSizes.push(runningSize);
    475      }
    476 
    477      // Access key are presented
    478      let complexTextNodesResult = this.highlightMatches(
    479        accessKeyTextNodes,
    480        nodeSizes,
    481        allNodeText.toLowerCase(),
    482        searchPhrase
    483      );
    484 
    485      // Searching some elements, such as xul:button, have a 'label' attribute that contains the user-visible text.
    486      let labelResult = this.queryMatchesContent(
    487        nodeObject.getAttribute("label"),
    488        searchPhrase
    489      );
    490 
    491      // Searching some elements, such as xul:label, store their user-visible text in a "value" attribute.
    492      // Value will be skipped for menuitem since value in menuitem could represent index number to distinct each item.
    493      let valueResult =
    494        nodeObject.localName !== "menuitem" && nodeObject.localName !== "radio"
    495          ? this.queryMatchesContent(
    496              nodeObject.getAttribute("value"),
    497              searchPhrase
    498            )
    499          : false;
    500 
    501      // Searching some elements, such as xul:button, buttons to open subdialogs
    502      // using l10n ids.
    503      let keywordsResult =
    504        nodeObject.hasAttribute("search-l10n-ids") &&
    505        (await this.matchesSearchL10nIDs(nodeObject, searchPhrase));
    506 
    507      if (!keywordsResult) {
    508        // Searching some elements, such as xul:button, buttons to open subdialogs
    509        // using searchkeywords attribute.
    510        keywordsResult =
    511          !keywordsResult &&
    512          nodeObject.hasAttribute("searchkeywords") &&
    513          this.queryMatchesContent(
    514            nodeObject.getAttribute("searchkeywords"),
    515            searchPhrase
    516          );
    517      }
    518 
    519      // Creating tooltips for buttons
    520      if (
    521        keywordsResult &&
    522        (nodeObject instanceof HTMLElement ||
    523          nodeObject.localName === "button" ||
    524          nodeObject.localName == "menulist")
    525      ) {
    526        this.listSearchTooltips.add(nodeObject);
    527      }
    528 
    529      if (keywordsResult && nodeObject.localName === "menuitem") {
    530        nodeObject.setAttribute("indicator", "true");
    531        this.listSearchMenuitemIndicators.add(nodeObject);
    532        let menulist = nodeObject.closest("menulist");
    533 
    534        menulist.setAttribute("indicator", "true");
    535        this.listSearchMenuitemIndicators.add(menulist);
    536      }
    537 
    538      if (
    539        (nodeObject.localName == "menulist" ||
    540          nodeObject.localName == "menuitem") &&
    541        (labelResult || valueResult || keywordsResult)
    542      ) {
    543        nodeObject.setAttribute("highlightable", "true");
    544      }
    545 
    546      matchesFound =
    547        matchesFound ||
    548        complexTextNodesResult ||
    549        labelResult ||
    550        valueResult ||
    551        keywordsResult;
    552    }
    553 
    554    // Should not search unselected child nodes of a <xul:deck> element
    555    // except the "historyPane" <xul:deck> element.
    556    if (nodeObject.localName == "deck" && nodeObject.id != "historyPane") {
    557      let index = nodeObject.selectedIndex;
    558      if (index != -1) {
    559        let result = await this.searchChildNodeIfVisible(
    560          nodeObject,
    561          index,
    562          searchPhrase
    563        );
    564        matchesFound = matchesFound || result;
    565      }
    566    } else {
    567      for (let i = 0; i < nodeObject.childNodes.length; i++) {
    568        let result = await this.searchChildNodeIfVisible(
    569          nodeObject,
    570          i,
    571          searchPhrase
    572        );
    573        matchesFound = matchesFound || result;
    574      }
    575    }
    576    return matchesFound;
    577  },
    578 
    579  /**
    580   * Search for a phrase within a child node if it is visible.
    581   *
    582   * @param Node nodeObject
    583   *    The parent DOM Element
    584   * @param Number index
    585   *    The index for the childNode
    586   * @param String searchPhrase
    587   * @returns boolean
    588   *    Returns true when found the specific childNode, false otherwise
    589   */
    590  async searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
    591    let result = false;
    592    let child = nodeObject.childNodes[index];
    593    if (
    594      !child.hidden &&
    595      nodeObject.getAttribute("data-hidden-from-search") !== "true"
    596    ) {
    597      result = await this.searchWithinNode(child, searchPhrase);
    598      // Creating tooltips for menulist element
    599      if (result && nodeObject.localName === "menulist") {
    600        this.listSearchTooltips.add(nodeObject);
    601      }
    602 
    603      // If this is a node for an experimental feature option or a Mozilla product item,
    604      // add it to the list of subitems. The items that don't match the search term
    605      // will be hidden.
    606      if (
    607        Element.isInstance(child) &&
    608        (child.classList.contains("featureGate") ||
    609          child.classList.contains("mozilla-product-item"))
    610      ) {
    611        this.subItems.set(child, result);
    612      }
    613    }
    614    return result;
    615  },
    616 
    617  /**
    618   * Search for a phrase in l10n messages associated with the element.
    619   *
    620   * @param Node nodeObject
    621   *    The parent DOM Element
    622   * @param String searchPhrase
    623   * @returns boolean
    624   *    true when the text content contains the query string else false
    625   */
    626  async matchesSearchL10nIDs(nodeObject, searchPhrase) {
    627    if (!this.searchKeywords.has(nodeObject)) {
    628      // The `search-l10n-ids` attribute is a comma-separated list of
    629      // l10n ids. It may also uses a dot notation to specify an attribute
    630      // of the message to be used.
    631      //
    632      // Example: "containers-add-button.label, user-context-personal"
    633      //
    634      // The result is an array of arrays of l10n ids and optionally attribute names.
    635      //
    636      // Example: [["containers-add-button", "label"], ["user-context-personal"]]
    637      const refs = nodeObject
    638        .getAttribute("search-l10n-ids")
    639        .split(",")
    640        .map(s => s.trim().split("."))
    641        .filter(s => !!s[0].length);
    642 
    643      const messages = await document.l10n.formatMessages(
    644        refs.map(ref => ({ id: ref[0] }))
    645      );
    646 
    647      // Map the localized messages taking value or a selected attribute and
    648      // building a string of concatenated translated strings out of it.
    649      let keywords = messages
    650        .map((msg, i) => {
    651          let [refId, refAttr] = refs[i];
    652          if (!msg) {
    653            console.error(`Missing search l10n id "${refId}"`);
    654            return null;
    655          }
    656          if (refAttr) {
    657            let attr =
    658              msg.attributes && msg.attributes.find(a => a.name === refAttr);
    659            if (!attr) {
    660              console.error(`Missing search l10n id "${refId}.${refAttr}"`);
    661              return null;
    662            }
    663            if (attr.value === "") {
    664              console.error(
    665                `Empty value added to search-l10n-ids "${refId}.${refAttr}"`
    666              );
    667            }
    668            return attr.value;
    669          }
    670          if (msg.value === "") {
    671            console.error(`Empty value added to search-l10n-ids "${refId}"`);
    672          }
    673          return msg.value;
    674        })
    675        .filter(keyword => keyword !== null)
    676        .join(" ");
    677 
    678      this.searchKeywords.set(nodeObject, keywords);
    679      return this.queryMatchesContent(keywords, searchPhrase);
    680    }
    681 
    682    return this.queryMatchesContent(
    683      this.searchKeywords.get(nodeObject),
    684      searchPhrase
    685    );
    686  },
    687 
    688  /**
    689   * Inserting a div structure infront of the DOM element matched textContent.
    690   * Then calculation the offsets to position the tooltip in the correct place.
    691   *
    692   * @param Node anchorNode
    693   *    DOM Element
    694   * @param String query
    695   *    Word or words that are being searched for
    696   */
    697  createSearchTooltip(anchorNode, query) {
    698    if (anchorNode.tooltipNode) {
    699      return;
    700    }
    701    let searchTooltip = anchorNode.ownerDocument.createElement("span");
    702    let searchTooltipText = anchorNode.ownerDocument.createElement("span");
    703    searchTooltip.className = "search-tooltip";
    704    searchTooltipText.textContent = query;
    705    searchTooltip.appendChild(searchTooltipText);
    706 
    707    // Set tooltipNode property to track corresponded tooltip node.
    708    anchorNode.tooltipNode = searchTooltip;
    709    anchorNode.parentElement.classList.add("search-tooltip-parent");
    710    this.searchTooltipContainer.append(searchTooltip);
    711 
    712    this._applyTooltipPosition(
    713      searchTooltip,
    714      this._computeTooltipPosition(anchorNode, searchTooltip)
    715    );
    716  },
    717 
    718  _recomputeTooltipPositions() {
    719    let positions = [];
    720    for (let anchorNode of this.listSearchTooltips) {
    721      let searchTooltip = anchorNode.tooltipNode;
    722      if (!searchTooltip) {
    723        continue;
    724      }
    725      let position = this._computeTooltipPosition(anchorNode, searchTooltip);
    726      positions.push({ searchTooltip, position });
    727    }
    728    for (let { searchTooltip, position } of positions) {
    729      this._applyTooltipPosition(searchTooltip, position);
    730    }
    731  },
    732 
    733  _applyTooltipPosition(searchTooltip, position) {
    734    searchTooltip.style.left = position.left + "px";
    735    searchTooltip.style.top = position.top + "px";
    736  },
    737 
    738  _computeTooltipPosition(anchorNode, searchTooltip) {
    739    // In order to get the up-to-date position of each of the nodes that we're
    740    // putting tooltips on, we have to flush layout intentionally. Once
    741    // menulists don't use XUL layout we can remove this and use plain CSS to
    742    // position them, see bug 1363730.
    743    let anchorRect = anchorNode.getBoundingClientRect();
    744    let tooltipContainerRect =
    745      this.searchTooltipContainer.getBoundingClientRect();
    746    let tooltipRect = searchTooltip.getBoundingClientRect();
    747 
    748    let top = anchorRect.top - tooltipContainerRect.top;
    749 
    750    let left;
    751    if (anchorRect.left <= tooltipContainerRect.left + 20) {
    752      // Left align on anchors that are close to the side of the main content
    753      left = 8;
    754    } else {
    755      // Center tooltips if their anchor is floating off somewhere else
    756      left =
    757        anchorRect.left -
    758        tooltipContainerRect.left +
    759        anchorRect.width / 2 -
    760        tooltipRect.width / 2;
    761    }
    762    return { left, top };
    763  },
    764 
    765  /**
    766   * Remove all search indicators. This would be called when switching away from
    767   * a search to another preference category.
    768   */
    769  removeAllSearchIndicators(window, showSubItems) {
    770    if (this.searchResultsHighlighted) {
    771      this.getFindSelection(window).removeAllRanges();
    772      this.searchResultsHighlighted = false;
    773    }
    774    this.removeAllSearchTooltips();
    775    this.removeAllSearchMenuitemIndicators();
    776 
    777    // Make any previously hidden subitems visible again for the next search.
    778    if (showSubItems && this.subItems.size) {
    779      for (let subItem of this.subItems.keys()) {
    780        subItem.classList.remove("visually-hidden");
    781      }
    782      this.subItems.clear();
    783    }
    784  },
    785 
    786  /**
    787   * Remove all search tooltips.
    788   */
    789  removeAllSearchTooltips() {
    790    for (let anchorNode of this.listSearchTooltips) {
    791      anchorNode.parentElement.classList.remove("search-tooltip-parent");
    792      if (anchorNode.tooltipNode) {
    793        anchorNode.tooltipNode.remove();
    794      }
    795      anchorNode.tooltipNode = null;
    796    }
    797    this.listSearchTooltips.clear();
    798  },
    799 
    800  /**
    801   * Remove all indicators on menuitem.
    802   */
    803  removeAllSearchMenuitemIndicators() {
    804    for (let node of this.listSearchMenuitemIndicators) {
    805      node.removeAttribute("indicator");
    806    }
    807    this.listSearchMenuitemIndicators.clear();
    808  },
    809 };