tor-browser

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

inspector-search.js (21079B)


      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
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
      8 
      9 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
     10 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
     11 
     12 // Maximum number of selector suggestions shown in the panel.
     13 const MAX_SUGGESTIONS = 15;
     14 
     15 /**
     16 * Converts any input field into a document search box.
     17 *
     18 * Emits the following events:
     19 * - search-cleared: when the search box is emptied
     20 * - search-result: when a search is made and a result is selected
     21 */
     22 class InspectorSearch {
     23  /**
     24   * @param {InspectorPanel} inspector
     25   *        The InspectorPanel to access the inspector commands for
     26   *        search and document traversal.
     27   * @param {HTMLElement} input
     28   *        The input element to which the panel will be attached and from where
     29   *        search input will be taken.
     30   * @param {HTMLElement} clearBtn
     31   *        The clear button in the input field that will clear the input value.
     32   * @param {HTMLElement} prevBtn
     33   *        The prev button in the search label that will move
     34   *        selection to previous match.
     35   * @param {HTMLElement} nextBtn
     36   *        The next button in the search label that will move
     37   *        selection to next match.
     38   */
     39  constructor(inspector, input, clearBtn, prevBtn, nextBtn) {
     40    this.inspector = inspector;
     41    this.searchBox = input;
     42    this.searchClearButton = clearBtn;
     43    this.searchPrevButton = prevBtn;
     44    this.searchNextButton = nextBtn;
     45    this._lastSearched = null;
     46 
     47    this._onKeyDown = this._onKeyDown.bind(this);
     48    this._onInput = this._onInput.bind(this);
     49    this.findPrev = this.findPrev.bind(this);
     50    this.findNext = this.findNext.bind(this);
     51    this._onClearSearch = this._onClearSearch.bind(this);
     52 
     53    this.searchBox.addEventListener("keydown", this._onKeyDown, true);
     54    this.searchBox.addEventListener("input", this._onInput, true);
     55    this.searchPrevButton.addEventListener("click", this.findPrev, true);
     56    this.searchNextButton.addEventListener("click", this.findNext, true);
     57    this.searchClearButton.addEventListener("click", this._onClearSearch);
     58 
     59    this.autocompleter = new SelectorAutocompleter(inspector, input);
     60    EventEmitter.decorate(this);
     61  }
     62  destroy() {
     63    this.searchBox.removeEventListener("keydown", this._onKeyDown, true);
     64    this.searchBox.removeEventListener("input", this._onInput, true);
     65    this.searchPrevButton.removeEventListener("click", this.findPrev, true);
     66    this.searchNextButton.removeEventListener("click", this.findNext, true);
     67    this.searchClearButton.removeEventListener("click", this._onClearSearch);
     68    this.searchBox = null;
     69    this.searchPrevButton = null;
     70    this.searchNextButton = null;
     71    this.searchClearButton = null;
     72    this.autocompleter.destroy();
     73  }
     74 
     75  _onSearch(reverse = false) {
     76    this.doFullTextSearch(this.searchBox.value, reverse).catch(console.error);
     77  }
     78 
     79  async doFullTextSearch(query, reverse) {
     80    const lastSearched = this._lastSearched;
     81    this._lastSearched = query;
     82 
     83    const searchContainer = this.searchBox.parentNode;
     84 
     85    if (query.length === 0) {
     86      searchContainer.classList.remove("devtools-searchbox-no-match");
     87      if (!lastSearched || lastSearched.length) {
     88        this.emit("search-cleared");
     89      }
     90      return;
     91    }
     92 
     93    const res = await this.inspector.commands.inspectorCommand.findNextNode(
     94      query,
     95      {
     96        reverse,
     97      }
     98    );
     99 
    100    // Value has changed since we started this request, we're done.
    101    if (query !== this.searchBox.value) {
    102      return;
    103    }
    104 
    105    if (res) {
    106      this.inspector.selection.setNodeFront(res.node, {
    107        reason: "inspectorsearch",
    108        searchQuery: query,
    109      });
    110      searchContainer.classList.remove("devtools-searchbox-no-match");
    111      res.query = query;
    112      this.emit("search-result", res);
    113    } else {
    114      searchContainer.classList.add("devtools-searchbox-no-match");
    115      this.emit("search-result");
    116    }
    117  }
    118 
    119  _onInput() {
    120    if (this.searchBox.value.length === 0) {
    121      this.searchClearButton.hidden = true;
    122      this._onSearch();
    123    } else {
    124      this.searchClearButton.hidden = false;
    125    }
    126  }
    127 
    128  _onKeyDown(event) {
    129    if (event.keyCode === KeyCodes.DOM_VK_RETURN) {
    130      this._onSearch(event.shiftKey);
    131    }
    132 
    133    const modifierKey =
    134      Services.appinfo.OS === "Darwin" ? event.metaKey : event.ctrlKey;
    135    if (event.keyCode === KeyCodes.DOM_VK_G && modifierKey) {
    136      this._onSearch(event.shiftKey);
    137      event.preventDefault();
    138    }
    139 
    140    // The search box is a `<input type="search">`, which would clear on pressing Escape.
    141    // Prevent the clear action from happening when the autocomplete popup is visible and
    142    // only hide it. The preventDefault has to be called during keydown and not keypress
    143    // to avoid the input clearance.
    144    if (
    145      event.keyCode === KeyCodes.DOM_VK_ESCAPE &&
    146      this.autocompleter.searchPopup?.isOpen
    147    ) {
    148      event.preventDefault();
    149      this.autocompleter.hidePopup();
    150    }
    151  }
    152 
    153  findNext() {
    154    this._onSearch();
    155  }
    156 
    157  findPrev() {
    158    this._onSearch(true);
    159  }
    160 
    161  _onClearSearch() {
    162    this.searchBox.parentNode.classList.remove("devtools-searchbox-no-match");
    163    this.searchBox.value = "";
    164    this.searchBox.focus();
    165    this.searchClearButton.hidden = true;
    166    this.emit("search-cleared");
    167  }
    168 }
    169 
    170 exports.InspectorSearch = InspectorSearch;
    171 
    172 /**
    173 * Converts any input box on a page to a CSS selector search and suggestion box.
    174 *
    175 * Emits 'processing-done' event when it is done processing the current
    176 * keypress, search request or selection from the list, whether that led to a
    177 * search or not.
    178 */
    179 class SelectorAutocompleter extends EventEmitter {
    180  /**
    181   * @param {InspectorPanel} inspector
    182   *        The InspectorPanel to access the inspector commands for
    183   *        search and document traversal.
    184   * @param {HTMLElement} inputNode
    185   *        The input element to which the panel will be attached and from where
    186   *        search input will be taken.
    187   */
    188  constructor(inspector, inputNode) {
    189    super();
    190 
    191    this.inspector = inspector;
    192    this.searchBox = inputNode;
    193    this.panelDoc = this.searchBox.ownerDocument;
    194 
    195    this.showSuggestions = this.showSuggestions.bind(this);
    196 
    197    // Options for the AutocompletePopup.
    198    const options = {
    199      listId: "searchbox-panel-listbox",
    200      autoSelect: true,
    201      position: "top",
    202      onClick: this.#onSearchPopupClick,
    203    };
    204 
    205    // The popup will be attached to the toolbox document.
    206    this.searchPopup = new AutocompletePopup(inspector.toolbox.doc, options);
    207 
    208    this.searchBox.addEventListener("input", this.showSuggestions, true);
    209    this.searchBox.addEventListener("keypress", this.#onSearchKeypress, true);
    210  }
    211 
    212  // The possible states of the query.
    213  States = {
    214    CLASS: "class",
    215    ID: "id",
    216    TAG: "tag",
    217    ATTRIBUTE: "attribute",
    218    // This is for pseudo classes (e.g. `:active`, `:not()`). We keep it as "pseudo" as
    219    // the server handles both pseudo elements and pseudo classes under the same type
    220    PSEUDO_CLASS: "pseudo",
    221    // This is for pseudo element (e.g. `::selection`)
    222    PSEUDO_ELEMENT: "pseudo-element",
    223  };
    224 
    225  // The current state of the query.
    226  #state = null;
    227 
    228  // The query corresponding to last state computation.
    229  #lastStateCheckAt = null;
    230 
    231  get walker() {
    232    return this.inspector.walker;
    233  }
    234 
    235  /**
    236   * Computes the state of the query. State refers to whether the query
    237   * currently requires a class suggestion, or a tag, or an Id suggestion.
    238   * This getter will effectively compute the state by traversing the query
    239   * character by character each time the query changes.
    240   *
    241   * @example
    242   *        '#f' requires an Id suggestion, so the state is States.ID
    243   *        'div > .foo' requires class suggestion, so state is States.CLASS
    244   */
    245  // eslint-disable-next-line complexity
    246  get state() {
    247    if (!this.searchBox || !this.searchBox.value) {
    248      return null;
    249    }
    250 
    251    const query = this.searchBox.value;
    252    if (this.#lastStateCheckAt == query) {
    253      // If query is the same, return early.
    254      return this.#state;
    255    }
    256    this.#lastStateCheckAt = query;
    257 
    258    this.#state = null;
    259    let subQuery = "";
    260    // Now we iterate over the query and decide the state character by
    261    // character.
    262    // The logic here is that while iterating, the state can go from one to
    263    // another with some restrictions. Like, if the state is Class, then it can
    264    // never go to Tag state without a space or '>' character; Or like, a Class
    265    // state with only '.' cannot go to an Id state without any [a-zA-Z] after
    266    // the '.' which means that '.#' is a selector matching a class name '#'.
    267    // Similarily for '#.' which means a selctor matching an id '.'.
    268    for (let i = 1; i <= query.length; i++) {
    269      // Calculate the state.
    270      subQuery = query.slice(0, i);
    271      let [secondLastChar, lastChar] = subQuery.slice(-2);
    272      switch (this.#state) {
    273        case null:
    274          // This will happen only in the first iteration of the for loop.
    275          lastChar = secondLastChar;
    276 
    277        case this.States.TAG: // eslint-disable-line
    278          if (lastChar === ".") {
    279            this.#state = this.States.CLASS;
    280          } else if (lastChar === "#") {
    281            this.#state = this.States.ID;
    282          } else if (lastChar === "[") {
    283            this.#state = this.States.ATTRIBUTE;
    284          } else if (lastChar === ":") {
    285            this.#state = this.States.PSEUDO_CLASS;
    286          } else if (lastChar === ")") {
    287            this.#state = null;
    288          } else {
    289            this.#state = this.States.TAG;
    290          }
    291          break;
    292 
    293        case this.States.CLASS:
    294          if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) {
    295            // Checks whether the subQuery has atleast one [a-zA-Z] after the
    296            // '.'.
    297            if (lastChar === " " || lastChar === ">") {
    298              this.#state = this.States.TAG;
    299            } else if (lastChar === "#") {
    300              this.#state = this.States.ID;
    301            } else if (lastChar === "[") {
    302              this.#state = this.States.ATTRIBUTE;
    303            } else if (lastChar === ":") {
    304              this.#state = this.States.PSEUDO_CLASS;
    305            } else if (lastChar === ")") {
    306              this.#state = null;
    307            } else {
    308              this.#state = this.States.CLASS;
    309            }
    310          }
    311          break;
    312 
    313        case this.States.ID:
    314          if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) {
    315            // Checks whether the subQuery has atleast one [a-zA-Z] after the
    316            // '#'.
    317            if (lastChar === " " || lastChar === ">") {
    318              this.#state = this.States.TAG;
    319            } else if (lastChar === ".") {
    320              this.#state = this.States.CLASS;
    321            } else if (lastChar === "[") {
    322              this.#state = this.States.ATTRIBUTE;
    323            } else if (lastChar === ":") {
    324              this.#state = this.States.PSEUDO_CLASS;
    325            } else if (lastChar === ")") {
    326              this.#state = null;
    327            } else {
    328              this.#state = this.States.ID;
    329            }
    330          }
    331          break;
    332 
    333        case this.States.ATTRIBUTE:
    334          if (subQuery.match(/[\[][^\]]+[\]]/) !== null) {
    335            // Checks whether the subQuery has at least one ']' after the '['.
    336            if (lastChar === " " || lastChar === ">") {
    337              this.#state = this.States.TAG;
    338            } else if (lastChar === ".") {
    339              this.#state = this.States.CLASS;
    340            } else if (lastChar === "#") {
    341              this.#state = this.States.ID;
    342            } else if (lastChar === ":") {
    343              this.#state = this.States.PSEUDO_CLASS;
    344            } else if (lastChar === ")") {
    345              this.#state = null;
    346            } else {
    347              this.#state = this.States.ATTRIBUTE;
    348            }
    349          }
    350          break;
    351 
    352        case this.States.PSEUDO_CLASS:
    353          if (lastChar === ":" && secondLastChar === ":") {
    354            // We don't support searching for pseudo elements, so bail out when we
    355            // see `::`
    356            this.#state = this.States.PSEUDO_ELEMENT;
    357            return this.#state;
    358          }
    359 
    360          if (lastChar === "(") {
    361            this.#state = null;
    362          } else if (lastChar === ".") {
    363            this.#state = this.States.CLASS;
    364          } else if (lastChar === "#") {
    365            this.#state = this.States.ID;
    366          } else {
    367            this.#state = this.States.PSEUDO_CLASS;
    368          }
    369          break;
    370      }
    371    }
    372    return this.#state;
    373  }
    374 
    375  /**
    376   * Removes event listeners and cleans up references.
    377   */
    378  destroy() {
    379    this.searchBox.removeEventListener("input", this.showSuggestions, true);
    380    this.searchBox.removeEventListener(
    381      "keypress",
    382      this.#onSearchKeypress,
    383      true
    384    );
    385    this.searchPopup.destroy();
    386    this.searchPopup = null;
    387    this.searchBox = null;
    388    this.panelDoc = null;
    389  }
    390 
    391  /**
    392   * Handles keypresses inside the input box.
    393   */
    394  #onSearchKeypress = event => {
    395    const popup = this.searchPopup;
    396    switch (event.keyCode) {
    397      case KeyCodes.DOM_VK_RETURN:
    398      case KeyCodes.DOM_VK_TAB:
    399        if (popup.isOpen) {
    400          if (popup.selectedItem) {
    401            this.searchBox.value = popup.selectedItem.label;
    402          }
    403          this.hidePopup();
    404        } else if (!popup.isOpen) {
    405          // When tab is pressed with focus on searchbox and closed popup,
    406          // do not prevent the default to avoid a keyboard trap and move focus
    407          // to next/previous element.
    408          this.emitForTests("processing-done");
    409          return;
    410        }
    411        break;
    412 
    413      case KeyCodes.DOM_VK_UP:
    414        if (popup.isOpen && popup.itemCount > 0) {
    415          popup.selectPreviousItem();
    416          this.searchBox.value = popup.selectedItem.label;
    417        }
    418        break;
    419 
    420      case KeyCodes.DOM_VK_DOWN:
    421        if (popup.isOpen && popup.itemCount > 0) {
    422          popup.selectNextItem();
    423          this.searchBox.value = popup.selectedItem.label;
    424        }
    425        break;
    426 
    427      case KeyCodes.DOM_VK_ESCAPE:
    428        if (popup.isOpen) {
    429          this.hidePopup();
    430        } else {
    431          this.emitForTests("processing-done");
    432          return;
    433        }
    434        break;
    435 
    436      default:
    437        return;
    438    }
    439 
    440    event.preventDefault();
    441    event.stopPropagation();
    442    this.emitForTests("processing-done");
    443  };
    444 
    445  /**
    446   * Handles click events from the autocomplete popup.
    447   */
    448  #onSearchPopupClick = event => {
    449    const selectedItem = this.searchPopup.selectedItem;
    450    if (selectedItem) {
    451      this.searchBox.value = selectedItem.label;
    452    }
    453    this.hidePopup();
    454 
    455    event.preventDefault();
    456    event.stopPropagation();
    457  };
    458 
    459  /**
    460   * Populates the suggestions list and show the suggestion popup.
    461   *
    462   * @param {Array} suggestions: List of suggestions
    463   * @param {string | null} popupState: One of SelectorAutocompleter.States
    464   * @return {Promise} promise that will resolve when the autocomplete popup is fully
    465   * displayed or hidden.
    466   */
    467  #showPopup(suggestions, popupState) {
    468    let total = 0;
    469    const query = this.searchBox.value;
    470    const items = [];
    471 
    472    for (let [value, state] of suggestions) {
    473      if (popupState === this.States.PSEUDO_CLASS) {
    474        value = query.substring(0, query.lastIndexOf(":")) + value;
    475      } else if (query.match(/[\s>+~]$/)) {
    476        // for cases like 'div ', 'div >', 'div+' or 'div~'
    477        value = query + value;
    478      } else if (query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)) {
    479        // for cases like 'div #a' or 'div .a' or 'div > d' and likewise
    480        const lastPart = query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)[0];
    481        value = query.slice(0, -1 * lastPart.length + 1) + value;
    482      } else if (query.match(/[a-zA-Z][#\.][^#\.\s+>~]*$/)) {
    483        // for cases like 'div.class' or '#foo.bar' and likewise
    484        const lastPart = query.match(/[a-zA-Z][#\.][^#\.\s+>~]*$/)[0];
    485        value = query.slice(0, -1 * lastPart.length + 1) + value;
    486      } else if (query.match(/[a-zA-Z]*\[[^\]]*\][^\]]*/)) {
    487        // for cases like '[foo].bar' and likewise
    488        const attrPart = query.substring(0, query.lastIndexOf("]") + 1);
    489        value = attrPart + value;
    490      }
    491 
    492      const item = {
    493        preLabel: query,
    494        label: value,
    495      };
    496 
    497      // In case the query's state is tag and the item's state is id or class
    498      // adjust the preLabel
    499      if (popupState === this.States.TAG && state === this.States.CLASS) {
    500        item.preLabel = "." + item.preLabel;
    501      }
    502      if (popupState === this.States.TAG && state === this.States.ID) {
    503        item.preLabel = "#" + item.preLabel;
    504      }
    505 
    506      items.push(item);
    507      if (++total > MAX_SUGGESTIONS - 1) {
    508        break;
    509      }
    510    }
    511 
    512    if (total > 0) {
    513      const onPopupOpened = this.searchPopup.once("popup-opened");
    514      this.searchPopup.once("popup-closed", () => {
    515        this.searchPopup.setItems(items);
    516        // The offset is left padding (22px) + left border width (1px) of searchBox.
    517        const xOffset = 23;
    518        this.searchPopup.openPopup(this.searchBox, xOffset);
    519      });
    520      this.searchPopup.hidePopup();
    521      return onPopupOpened;
    522    }
    523 
    524    return this.hidePopup();
    525  }
    526 
    527  /**
    528   * Hide the suggestion popup if necessary.
    529   */
    530  hidePopup() {
    531    const onPopupClosed = this.searchPopup.once("popup-closed");
    532    this.searchPopup.hidePopup();
    533    return onPopupClosed;
    534  }
    535 
    536  /**
    537   * Suggests classes,ids and tags based on the user input as user types in the
    538   * searchbox.
    539   */
    540  async showSuggestions() {
    541    let query = this.searchBox.value;
    542    const originalQuery = this.searchBox.value;
    543 
    544    const state = this.state;
    545    let completing = "";
    546 
    547    if (
    548      // Hide the popup if:
    549      // - the query is empty
    550      !query ||
    551      // - the query ends with * (because we don't want to suggest all nodes)
    552      query.endsWith("*") ||
    553      // - if it is an attribute selector (because it would give a lot of useless results).
    554      state === this.States.ATTRIBUTE ||
    555      // - if it is a pseudo element selector (we don't support it, see Bug 1097991)
    556      state === this.States.PSEUDO_ELEMENT
    557    ) {
    558      this.hidePopup();
    559      this.emitForTests("processing-done", { query: originalQuery });
    560      return;
    561    }
    562 
    563    if (state === this.States.TAG) {
    564      // gets the tag that is being completed. For ex:
    565      // - 'di' returns 'di'
    566      // - 'div.foo s' returns 's'
    567      // - 'div.foo > s' returns 's'
    568      // - 'div.foo + s' returns 's'
    569      // - 'div.foo ~ s' returns 's'
    570      // - 'div.foo x-el_1' returns 'x-el_1'
    571      const matches = query.match(/[\s>+~]?(?<tag>[a-zA-Z0-9_-]*)$/);
    572      completing = matches.groups.tag;
    573      query = query.slice(0, query.length - completing.length);
    574    } else if (state === this.States.CLASS) {
    575      // gets the class that is being completed. For ex. '.foo.b' returns 'b'
    576      completing = query.match(/\.([^\.]*)$/)[1];
    577      query = query.slice(0, query.length - completing.length - 1);
    578    } else if (state === this.States.ID) {
    579      // gets the id that is being completed. For ex. '.foo#b' returns 'b'
    580      completing = query.match(/#([^#]*)$/)[1];
    581      query = query.slice(0, query.length - completing.length - 1);
    582    } else if (state === this.States.PSEUDO_CLASS) {
    583      // The getSuggestionsForQuery expects a pseudo element without the : prefix
    584      completing = query.substring(query.lastIndexOf(":") + 1);
    585      query = "";
    586    }
    587    // TODO: implement some caching so that over the wire request is not made
    588    // everytime.
    589    if (/[\s+>~]$/.test(query)) {
    590      query += "*";
    591    }
    592 
    593    let suggestions =
    594      await this.inspector.commands.inspectorCommand.getSuggestionsForQuery(
    595        query,
    596        completing,
    597        state
    598      );
    599 
    600    if (state === this.States.CLASS) {
    601      completing = "." + completing;
    602    } else if (state === this.States.ID) {
    603      completing = "#" + completing;
    604    } else if (state === this.States.PSEUDO_CLASS) {
    605      completing = ":" + completing;
    606      // Remove pseudo-element suggestions, since the search does not work with them (Bug 1097991)
    607      suggestions = suggestions.filter(
    608        suggestion => !suggestion[0].startsWith("::")
    609      );
    610    }
    611 
    612    // If there is a single tag match and it's what the user typed, then
    613    // don't need to show a popup.
    614    if (suggestions.length === 1 && suggestions[0][0] === completing) {
    615      suggestions = [];
    616    }
    617 
    618    // Wait for the autocomplete-popup to fire its popup-opened event, to make sure
    619    // the autoSelect item has been selected.
    620    await this.#showPopup(suggestions, state);
    621    this.emitForTests("processing-done", { query: originalQuery });
    622  }
    623 }
    624 
    625 exports.SelectorAutocompleter = SelectorAutocompleter;