tor-browser

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

class-list-previewer.js (9424B)


      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 ClassList = require("resource://devtools/client/inspector/rules/models/class-list.js");
      8 
      9 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
     10 const L10N = new LocalizationHelper(
     11  "devtools/client/locales/inspector.properties"
     12 );
     13 const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js");
     14 const { debounce } = require("resource://devtools/shared/debounce.js");
     15 
     16 /**
     17 * This UI widget shows a textfield and a series of checkboxes in the rule-view. It is
     18 * used to toggle classes on the current node selection, and add new classes.
     19 */
     20 class ClassListPreviewer {
     21  /**
     22   * @param {Inspector} inspector
     23   *        The current inspector instance.
     24   * @param {DomNode} containerEl
     25   *        The element in the rule-view where the widget should go.
     26   */
     27  constructor(inspector, containerEl) {
     28    this.inspector = inspector;
     29    this.containerEl = containerEl;
     30    this.model = new ClassList(inspector);
     31 
     32    this.onNewSelection = this.onNewSelection.bind(this);
     33    this.onCheckBoxChanged = this.onCheckBoxChanged.bind(this);
     34    this.onKeyDown = this.onKeyDown.bind(this);
     35    this.onAddElementInputModified = debounce(
     36      this.onAddElementInputModified,
     37      75,
     38      this
     39    );
     40    this.onCurrentNodeClassChanged = this.onCurrentNodeClassChanged.bind(this);
     41    this.onNodeFrontWillUnset = this.onNodeFrontWillUnset.bind(this);
     42    this.onAutocompleteClassHovered = debounce(
     43      this.onAutocompleteClassHovered,
     44      75,
     45      this
     46    );
     47    this.onAutocompleteClosed = this.onAutocompleteClosed.bind(this);
     48 
     49    // Create the add class text field.
     50    this.addEl = this.doc.createElement("input");
     51    this.addEl.classList.add("devtools-textinput");
     52    this.addEl.classList.add("add-class");
     53    this.addEl.setAttribute(
     54      "placeholder",
     55      L10N.getStr("inspector.classPanel.newClass.placeholder")
     56    );
     57    this.addEl.addEventListener("keydown", this.onKeyDown);
     58    this.addEl.addEventListener("input", this.onAddElementInputModified);
     59    this.containerEl.appendChild(this.addEl);
     60 
     61    // Create the class checkboxes container.
     62    this.classesEl = this.doc.createElement("div");
     63    this.classesEl.classList.add("classes");
     64    this.containerEl.appendChild(this.classesEl);
     65 
     66    // Create the autocomplete popup
     67    this.autocompletePopup = new AutocompletePopup(this.inspector.toolbox.doc, {
     68      listId: "inspector_classListPreviewer_autocompletePopupListBox",
     69      position: "bottom",
     70      autoSelect: true,
     71      useXulWrapper: true,
     72      input: this.addEl,
     73      onClick: (e, item) => {
     74        if (item) {
     75          this.addEl.value = item.label;
     76          this.autocompletePopup.hidePopup();
     77          this.autocompletePopup.clearItems();
     78          this.model.previewClass(item.label);
     79        }
     80      },
     81      onSelect: item => {
     82        if (item) {
     83          this.onAutocompleteClassHovered(item?.label);
     84        }
     85      },
     86    });
     87 
     88    // Start listening for interesting events.
     89    this.inspector.selection.on("new-node-front", this.onNewSelection);
     90    this.inspector.selection.on(
     91      "node-front-will-unset",
     92      this.onNodeFrontWillUnset
     93    );
     94    this.containerEl.addEventListener("input", this.onCheckBoxChanged);
     95    this.model.on("current-node-class-changed", this.onCurrentNodeClassChanged);
     96    this.autocompletePopup.on("popup-closed", this.onAutocompleteClosed);
     97 
     98    this.onNewSelection();
     99  }
    100 
    101  destroy() {
    102    this.inspector.selection.off("new-node-front", this.onNewSelection);
    103    this.inspector.selection.off(
    104      "node-front-will-unset",
    105      this.onNodeFrontWillUnset
    106    );
    107    this.autocompletePopup.off("popup-closed", this.onAutocompleteClosed);
    108    this.addEl.removeEventListener("keydown", this.onKeyDown);
    109    this.addEl.removeEventListener("input", this.onAddElementInputModified);
    110    this.containerEl.removeEventListener("input", this.onCheckBoxChanged);
    111 
    112    this.autocompletePopup.destroy();
    113 
    114    this.containerEl.innerHTML = "";
    115 
    116    this.model.destroy();
    117    this.containerEl = null;
    118    this.inspector = null;
    119    this.addEl = null;
    120    this.classesEl = null;
    121  }
    122 
    123  get doc() {
    124    return this.containerEl.ownerDocument;
    125  }
    126 
    127  /**
    128   * Render the content of the panel. You typically don't need to call this as the panel
    129   * renders itself on inspector selection changes.
    130   */
    131  render() {
    132    this.classesEl.innerHTML = "";
    133 
    134    for (const { name, isApplied } of this.model.currentClasses) {
    135      const checkBox = this.renderCheckBox(name, isApplied);
    136      this.classesEl.appendChild(checkBox);
    137    }
    138 
    139    if (!this.model.currentClasses.length) {
    140      this.classesEl.appendChild(this.renderNoClassesMessage());
    141    }
    142  }
    143 
    144  /**
    145   * Render a single checkbox for a given classname.
    146   *
    147   * @param {string} name
    148   *        The name of this class.
    149   * @param {boolean} isApplied
    150   *        Is this class currently applied on the DOM node.
    151   * @return {DOMNode} The DOM element for this checkbox.
    152   */
    153  renderCheckBox(name, isApplied) {
    154    const box = this.doc.createElement("input");
    155    box.setAttribute("type", "checkbox");
    156    if (isApplied) {
    157      box.setAttribute("checked", "checked");
    158    }
    159    box.dataset.name = name;
    160 
    161    const labelWrapper = this.doc.createElement("label");
    162    labelWrapper.setAttribute("title", name);
    163    labelWrapper.appendChild(box);
    164 
    165    // A child element is required to do the ellipsis.
    166    const label = this.doc.createElement("span");
    167    label.textContent = name;
    168    labelWrapper.appendChild(label);
    169 
    170    return labelWrapper;
    171  }
    172 
    173  /**
    174   * Render the message displayed in the panel when the current element has no classes.
    175   *
    176   * @return {DOMNode} The DOM element for the message.
    177   */
    178  renderNoClassesMessage() {
    179    const msg = this.doc.createElement("p");
    180    msg.classList.add("no-classes");
    181    msg.textContent = L10N.getStr("inspector.classPanel.noClasses");
    182    return msg;
    183  }
    184 
    185  /**
    186   * Focus the add-class text field.
    187   */
    188  focusAddClassField() {
    189    if (this.addEl) {
    190      this.addEl.focus();
    191    }
    192  }
    193 
    194  onCheckBoxChanged({ target }) {
    195    if (!target.dataset.name) {
    196      return;
    197    }
    198 
    199    this.model.setClassState(target.dataset.name, target.checked).catch(e => {
    200      // Only log the error if the panel wasn't destroyed in the meantime.
    201      if (this.containerEl) {
    202        console.error(e);
    203      }
    204    });
    205  }
    206 
    207  onKeyDown(event) {
    208    // If the popup is already open, all the keyboard interaction are handled
    209    // directly by the popup component.
    210    if (this.autocompletePopup.isOpen) {
    211      return;
    212    }
    213 
    214    // Open the autocomplete popup on Ctrl+Space / ArrowDown (when the input isn't empty)
    215    if (
    216      (this.addEl.value && event.key === " " && event.ctrlKey) ||
    217      event.key === "ArrowDown"
    218    ) {
    219      this.onAddElementInputModified();
    220      return;
    221    }
    222 
    223    if (this.addEl.value !== "" && event.key === "Enter") {
    224      this.addClassName(this.addEl.value);
    225    }
    226  }
    227 
    228  async onAddElementInputModified() {
    229    const newValue = this.addEl.value;
    230 
    231    // if the input is empty, let's close the popup, if it was open.
    232    if (newValue === "") {
    233      if (this.autocompletePopup.isOpen) {
    234        this.autocompletePopup.hidePopup();
    235        this.autocompletePopup.clearItems();
    236      } else {
    237        this.model.previewClass("");
    238      }
    239      return;
    240    }
    241 
    242    // Otherwise, we need to update the popup items to match the new input.
    243    let items = [];
    244    try {
    245      const classNames = await this.model.getClassNames(newValue);
    246      if (!this.autocompletePopup.isOpen) {
    247        this._previewClassesBeforeAutocompletion =
    248          this.model.previewClasses.map(previewClass => previewClass.className);
    249      }
    250      items = classNames.map(className => {
    251        return {
    252          preLabel: className.substring(0, newValue.length),
    253          label: className,
    254        };
    255      });
    256    } catch (e) {
    257      // If there was an error while retrieving the classNames, we'll simply NOT show the
    258      // popup, which is okay.
    259      console.warn("Error when calling getClassNames", e);
    260    }
    261 
    262    if (!items.length || (items.length == 1 && items[0].label === newValue)) {
    263      this.autocompletePopup.clearItems();
    264      await this.autocompletePopup.hidePopup();
    265      this.model.previewClass(newValue);
    266    } else {
    267      this.autocompletePopup.setItems(items);
    268      this.autocompletePopup.openPopup();
    269    }
    270  }
    271 
    272  async addClassName(className) {
    273    try {
    274      await this.model.addClassName(className);
    275      this.render();
    276      this.addEl.value = "";
    277    } catch (e) {
    278      // Only log the error if the panel wasn't destroyed in the meantime.
    279      if (this.containerEl) {
    280        console.error(e);
    281      }
    282    }
    283  }
    284 
    285  onNewSelection() {
    286    this.render();
    287  }
    288 
    289  onCurrentNodeClassChanged() {
    290    this.render();
    291  }
    292 
    293  onNodeFrontWillUnset() {
    294    this.model.eraseClassPreview();
    295    this.addEl.value = "";
    296  }
    297 
    298  onAutocompleteClassHovered(autocompleteItemLabel = "") {
    299    if (this.autocompletePopup.isOpen) {
    300      this.model.previewClass(autocompleteItemLabel);
    301    }
    302  }
    303 
    304  onAutocompleteClosed() {
    305    const inputValue = this.addEl.value;
    306    this.model.previewClass(inputValue);
    307  }
    308 }
    309 
    310 module.exports = ClassListPreviewer;