tor-browser

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

TreeWidget.js (18400B)


      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 "use strict";
      5 
      6 const HTML_NS = "http://www.w3.org/1999/xhtml";
      7 
      8 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
      9 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
     10 
     11 /**
     12 * A tree widget with keyboard navigation and collapsable structure.
     13 */
     14 class TreeWidget extends EventEmitter {
     15  /**
     16   * @param {Node} node
     17   *        The container element for the tree widget.
     18   * @param {object} options
     19   * @param {string} [options.emptyText]  text to display when no entries in the table.
     20   * @param {string} options.defaultType  The default type of the tree items. For ex.
     21   *  'js'
     22   * @param {boolean} [options.sorted]  Defaults to true. If true, tree items are kept in
     23   *  lexical order. If false, items will be kept in insertion order.
     24   * @param {string} [options.contextMenuId] ID of context menu to be displayed on
     25   *  tree items.
     26   */
     27  constructor(node, options = {}) {
     28    super();
     29 
     30    this.document = node.ownerDocument;
     31    this.window = this.document.defaultView;
     32    this._parent = node;
     33 
     34    this.emptyText = options.emptyText || "";
     35    this.defaultType = options.defaultType;
     36    this.sorted = options.sorted !== false;
     37    this.contextMenuId = options.contextMenuId;
     38 
     39    this.setupRoot();
     40 
     41    this.placeholder = this.document.createElementNS(HTML_NS, "label");
     42    this.placeholder.className = "tree-widget-empty-text";
     43    this._parent.appendChild(this.placeholder);
     44 
     45    if (this.emptyText) {
     46      this.setPlaceholderText(this.emptyText);
     47    }
     48    // A map to hold all the passed attachment to each leaf in the tree.
     49    this.attachments = new Map();
     50  }
     51 
     52  _selectedLabel = null;
     53  _selectedItem = null;
     54  /**
     55   * Select any node in the tree.
     56   *
     57   * @param {Array} ids
     58   *        An array of ids leading upto the selected item
     59   */
     60  set selectedItem(ids) {
     61    if (this._selectedLabel) {
     62      this._selectedLabel.classList.remove("theme-selected");
     63    }
     64    const currentSelected = this._selectedLabel;
     65    if (ids == -1) {
     66      this._selectedLabel = this._selectedItem = null;
     67      return;
     68    }
     69    if (!Array.isArray(ids)) {
     70      return;
     71    }
     72    this._selectedLabel = this.root.setSelectedItem(ids);
     73    if (!this._selectedLabel) {
     74      this._selectedItem = null;
     75    } else {
     76      if (currentSelected != this._selectedLabel) {
     77        this.ensureSelectedVisible();
     78      }
     79      this._selectedItem = ids;
     80      this.emit(
     81        "select",
     82        this._selectedItem,
     83        this.attachments.get(JSON.stringify(ids))
     84      );
     85    }
     86  }
     87 
     88  /**
     89   * Gets the selected item in the tree.
     90   *
     91   * @return {Array}
     92   *        An array of ids leading upto the selected item
     93   */
     94  get selectedItem() {
     95    return this._selectedItem;
     96  }
     97 
     98  /**
     99   * Returns if the passed array corresponds to the selected item in the tree.
    100   *
    101   * @return {Array}
    102   *        An array of ids leading upto the requested item
    103   */
    104  isSelected(item) {
    105    if (!this._selectedItem || this._selectedItem.length != item.length) {
    106      return false;
    107    }
    108 
    109    for (let i = 0; i < this._selectedItem.length; i++) {
    110      if (this._selectedItem[i] != item[i]) {
    111        return false;
    112      }
    113    }
    114 
    115    return true;
    116  }
    117 
    118  destroy() {
    119    this.root.remove();
    120    this.root = null;
    121  }
    122 
    123  /**
    124   * Sets up the root container of the TreeWidget.
    125   */
    126  setupRoot() {
    127    this.root = new TreeItem(this.document);
    128    if (this.contextMenuId) {
    129      this.root.children.addEventListener("contextmenu", event => {
    130        // Call stopPropagation() and preventDefault() here so that avoid to show default
    131        // context menu in about:devtools-toolbox. See Bug 1515265.
    132        event.stopPropagation();
    133        event.preventDefault();
    134        const menu = this.document.getElementById(this.contextMenuId);
    135        menu.openPopupAtScreen(event.screenX, event.screenY, true);
    136      });
    137    }
    138 
    139    this._parent.appendChild(this.root.children);
    140 
    141    this.root.children.addEventListener("mousedown", e => this.onClick(e));
    142    this.root.children.addEventListener("keydown", e => this.onKeydown(e));
    143  }
    144 
    145  /**
    146   * Sets the text to be shown when no node is present in the tree.
    147   * The placeholder will be hidden if text is empty.
    148   */
    149  setPlaceholderText(text) {
    150    this.placeholder.textContent = text;
    151    if (text) {
    152      this.placeholder.removeAttribute("hidden");
    153    } else {
    154      this.placeholder.setAttribute("hidden", "true");
    155    }
    156  }
    157 
    158  /**
    159   * Select any node in the tree.
    160   *
    161   * @param {Array} id
    162   *        An array of ids leading upto the selected item
    163   */
    164  selectItem(id) {
    165    this.selectedItem = id;
    166  }
    167 
    168  /**
    169   * Selects the next visible item in the tree.
    170   */
    171  selectNextItem() {
    172    const next = this.getNextVisibleItem();
    173    if (next) {
    174      this.selectedItem = next;
    175    }
    176  }
    177 
    178  /**
    179   * Selects the previos visible item in the tree
    180   */
    181  selectPreviousItem() {
    182    const prev = this.getPreviousVisibleItem();
    183    if (prev) {
    184      this.selectedItem = prev;
    185    }
    186  }
    187 
    188  /**
    189   * Returns the next visible item in the tree
    190   */
    191  getNextVisibleItem() {
    192    let node = this._selectedLabel;
    193    if (node.hasAttribute("expanded") && node.nextSibling.firstChild) {
    194      return JSON.parse(node.nextSibling.firstChild.getAttribute("data-id"));
    195    }
    196    node = node.parentNode;
    197    if (node.nextSibling) {
    198      return JSON.parse(node.nextSibling.getAttribute("data-id"));
    199    }
    200    node = node.parentNode;
    201    while (node.parentNode && node != this.root.children) {
    202      if (node.parentNode?.nextSibling) {
    203        return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id"));
    204      }
    205      node = node.parentNode;
    206    }
    207    return null;
    208  }
    209 
    210  /**
    211   * Returns the previous visible item in the tree
    212   */
    213  getPreviousVisibleItem() {
    214    let node = this._selectedLabel.parentNode;
    215    if (node.previousSibling) {
    216      node = node.previousSibling.firstChild;
    217      while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
    218        if (!node.nextSibling.lastChild) {
    219          break;
    220        }
    221        node = node.nextSibling.lastChild.firstChild;
    222      }
    223      return JSON.parse(node.parentNode.getAttribute("data-id"));
    224    }
    225    node = node.parentNode;
    226    if (node.parentNode && node != this.root.children) {
    227      node = node.parentNode;
    228      while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) {
    229        if (!node.nextSibling.firstChild) {
    230          break;
    231        }
    232        node = node.nextSibling.firstChild.firstChild;
    233      }
    234      return JSON.parse(node.getAttribute("data-id"));
    235    }
    236    return null;
    237  }
    238 
    239  clearSelection() {
    240    this.selectedItem = -1;
    241  }
    242 
    243  /**
    244   * Adds an item in the tree. The item can be added as a child to any node in
    245   * the tree. The method will also create any subnode not present in the
    246   * process.
    247   *
    248   * @param {[string|object]} items
    249   *        An array of either string or objects where each increasing index
    250   *        represents an item corresponding to an equivalent depth in the tree.
    251   *        Each array element can be either just a string with the value as the
    252   *        id of of that item as well as the display value, or it can be an
    253   *        object with the following propeties:
    254   *          - id {string} The id of the item
    255   *          - label {string} The display value of the item
    256   *          - node {DOMNode} The dom node if you want to insert some custom
    257   *                 element as the item. The label property is not used in this
    258   *                 case
    259   *          - attachment {object} Any object to be associated with this item.
    260   *          - type {string} The type of this particular item. If this is null,
    261   *                 then defaultType will be used.
    262   *        For example, if items = ["foo", "bar", { id: "id1", label: "baz" }]
    263   *        and the tree is empty, then the following hierarchy will be created
    264   *        in the tree:
    265   *        foo
    266   *         └ bar
    267   *            └ baz
    268   *        Passing the string id instead of the complete object helps when you
    269   *        are simply adding children to an already existing node and you know
    270   *        its id.
    271   */
    272  add(items) {
    273    this.root.add(items, this.defaultType, this.sorted);
    274    for (let i = 0; i < items.length; i++) {
    275      if (items[i].attachment) {
    276        this.attachments.set(
    277          JSON.stringify(items.slice(0, i + 1).map(item => item.id || item)),
    278          items[i].attachment
    279        );
    280      }
    281    }
    282    // Empty the empty-tree-text
    283    this.setPlaceholderText("");
    284  }
    285 
    286  /**
    287   * Check if an item exists.
    288   *
    289   * @param {Array} item
    290   *        The array of ids leading up to the item.
    291   */
    292  exists(item) {
    293    let bookmark = this.root;
    294 
    295    for (const id of item) {
    296      if (bookmark.items.has(id)) {
    297        bookmark = bookmark.items.get(id);
    298      } else {
    299        return false;
    300      }
    301    }
    302    return true;
    303  }
    304 
    305  /**
    306   * Removes the specified item and all of its child items from the tree.
    307   *
    308   * @param {Array} item
    309   *        The array of ids leading up to the item.
    310   */
    311  remove(item) {
    312    this.root.remove(item);
    313    this.attachments.delete(JSON.stringify(item));
    314    // Display the empty tree text
    315    if (this.root.items.size == 0 && this.emptyText) {
    316      this.setPlaceholderText(this.emptyText);
    317    }
    318  }
    319 
    320  /**
    321   * Removes all of the child nodes from this tree.
    322   */
    323  clear() {
    324    this.root.remove();
    325    this.setupRoot();
    326    this.attachments.clear();
    327    if (this.emptyText) {
    328      this.setPlaceholderText(this.emptyText);
    329    }
    330  }
    331 
    332  /**
    333   * Expands the tree completely
    334   */
    335  expandAll() {
    336    this.root.expandAll();
    337  }
    338 
    339  /**
    340   * Collapses the tree completely
    341   */
    342  collapseAll() {
    343    this.root.collapseAll();
    344  }
    345 
    346  /**
    347   * Click handler for the tree. Used to select, open and close the tree nodes.
    348   */
    349  onClick(event) {
    350    let target = event.originalTarget;
    351    while (target && !target.classList.contains("tree-widget-item")) {
    352      if (target == this.root.children) {
    353        return;
    354      }
    355      target = target.parentNode;
    356    }
    357    if (!target) {
    358      return;
    359    }
    360 
    361    if (target.hasAttribute("expanded")) {
    362      target.removeAttribute("expanded");
    363    } else {
    364      target.setAttribute("expanded", "true");
    365    }
    366 
    367    if (this._selectedLabel != target) {
    368      const ids = target.parentNode.getAttribute("data-id");
    369      this.selectedItem = JSON.parse(ids);
    370    }
    371  }
    372 
    373  /**
    374   * Keydown handler for this tree. Used to select next and previous visible
    375   * items, as well as collapsing and expanding any item.
    376   */
    377  onKeydown(event) {
    378    switch (event.keyCode) {
    379      case KeyCodes.DOM_VK_UP:
    380        this.selectPreviousItem();
    381        break;
    382 
    383      case KeyCodes.DOM_VK_DOWN:
    384        this.selectNextItem();
    385        break;
    386 
    387      case KeyCodes.DOM_VK_RIGHT:
    388        if (this._selectedLabel.hasAttribute("expanded")) {
    389          this.selectNextItem();
    390        } else {
    391          this._selectedLabel.setAttribute("expanded", "true");
    392        }
    393        break;
    394 
    395      case KeyCodes.DOM_VK_LEFT:
    396        if (
    397          this._selectedLabel.hasAttribute("expanded") &&
    398          !this._selectedLabel.hasAttribute("empty")
    399        ) {
    400          this._selectedLabel.removeAttribute("expanded");
    401        } else {
    402          this.selectPreviousItem();
    403        }
    404        break;
    405 
    406      default:
    407        return;
    408    }
    409    event.preventDefault();
    410  }
    411 
    412  /**
    413   * Scrolls the viewport of the tree so that the selected item is always
    414   * visible.
    415   */
    416  ensureSelectedVisible() {
    417    const { top, bottom } = this._selectedLabel.getBoundingClientRect();
    418    const height = this.root.children.parentNode.clientHeight;
    419    if (top < 0) {
    420      this._selectedLabel.scrollIntoView();
    421    } else if (bottom > height) {
    422      this._selectedLabel.scrollIntoView(false);
    423    }
    424  }
    425 }
    426 
    427 module.exports.TreeWidget = TreeWidget;
    428 
    429 /**
    430 * Any item in the tree. This can be an empty leaf node also.
    431 */
    432 class TreeItem {
    433  /**
    434   * @param {HTMLDocument} document
    435   *        The document element used for creating new nodes.
    436   * @param {TreeItem} parent
    437   *        The parent item for this item.
    438   * @param {string|DOMElement} label
    439   *        Either the dom node to be used as the item, or the string to be
    440   *        displayed for this node in the tree
    441   * @param {string} type
    442   *        The type of the current node. For ex. "js"
    443   */
    444  constructor(document, parent, label, type) {
    445    this.document = document;
    446    this.node = this.document.createElementNS(HTML_NS, "li");
    447    this.node.setAttribute("tabindex", "0");
    448    this.isRoot = !parent;
    449    this.parent = parent;
    450    if (this.parent) {
    451      this.level = this.parent.level + 1;
    452    }
    453    if (label) {
    454      this.label = this.document.createElementNS(HTML_NS, "div");
    455      this.label.setAttribute("empty", "true");
    456      this.label.setAttribute("level", this.level);
    457      this.label.className = "tree-widget-item";
    458      if (type) {
    459        this.label.setAttribute("type", type);
    460      }
    461      if (typeof label == "string") {
    462        this.label.textContent = label;
    463      } else {
    464        this.label.appendChild(label);
    465      }
    466      this.node.appendChild(this.label);
    467    }
    468    this.children = this.document.createElementNS(HTML_NS, "ul");
    469    if (this.isRoot) {
    470      this.children.className = "tree-widget-container";
    471    } else {
    472      this.children.className = "tree-widget-children";
    473    }
    474    this.node.appendChild(this.children);
    475    this.items = new Map();
    476  }
    477 
    478  items = null;
    479 
    480  isSelected = false;
    481 
    482  expanded = false;
    483 
    484  isRoot = false;
    485 
    486  parent = null;
    487 
    488  children = null;
    489 
    490  level = 0;
    491 
    492  /**
    493   * Adds the item to the sub tree contained by this node. The item to be
    494   * inserted can be a direct child of this node, or further down the tree.
    495   *
    496   * @param {Array} items
    497   *        Same as TreeWidget.add method's argument
    498   * @param {string} defaultType
    499   *        The default type of the item to be used when items[i].type is null
    500   * @param {boolean} sorted
    501   *        true if the tree items are inserted in a lexically sorted manner.
    502   *        Otherwise, false if the item are to be appended to their parent.
    503   */
    504  add(items, defaultType, sorted) {
    505    if (items.length == this.level) {
    506      // This is the exit condition of recursive TreeItem.add calls
    507      return;
    508    }
    509    // Get the id and label corresponding to this level inside the tree.
    510    const id = items[this.level].id || items[this.level];
    511    if (this.items.has(id)) {
    512      // An item with same id already exists, thus calling the add method of
    513      // that child to add the passed node at correct position.
    514      this.items.get(id).add(items, defaultType, sorted);
    515      return;
    516    }
    517    // No item with the id `id` exists, so we create one and call the add
    518    // method of that item.
    519    // The display string of the item can be the label, the id, or the item
    520    // itself if its a plain string.
    521    let label =
    522      items[this.level].label || items[this.level].id || items[this.level];
    523    const node = items[this.level].node;
    524    if (node) {
    525      // The item is supposed to be a DOMNode, so we fetch the textContent in
    526      // order to find the correct sorted location of this new item.
    527      label = node.textContent;
    528    }
    529    const treeItem = new TreeItem(
    530      this.document,
    531      this,
    532      node || label,
    533      items[this.level].type || defaultType
    534    );
    535 
    536    treeItem.add(items, defaultType, sorted);
    537    treeItem.node.setAttribute(
    538      "data-id",
    539      JSON.stringify(
    540        items.slice(0, this.level + 1).map(item => item.id || item)
    541      )
    542    );
    543 
    544    if (sorted) {
    545      // Inserting this newly created item at correct position
    546      const nextSibling = [...this.items.values()].find(child => {
    547        return child.label.textContent >= label;
    548      });
    549 
    550      if (nextSibling) {
    551        this.children.insertBefore(treeItem.node, nextSibling.node);
    552      } else {
    553        this.children.appendChild(treeItem.node);
    554      }
    555    } else {
    556      this.children.appendChild(treeItem.node);
    557    }
    558 
    559    if (this.label) {
    560      this.label.removeAttribute("empty");
    561    }
    562    this.items.set(id, treeItem);
    563  }
    564 
    565  /**
    566   * If this item is to be removed, then removes this item and thus all of its
    567   * subtree. Otherwise, call the remove method of appropriate child. This
    568   * recursive method goes on till we have reached the end of the branch or the
    569   * current item is to be removed.
    570   *
    571   * @param {Array} items
    572   *        Ids of items leading up to the item to be removed.
    573   */
    574  remove(items = []) {
    575    const id = items.shift();
    576    if (id && this.items.has(id)) {
    577      const deleted = this.items.get(id);
    578      if (!items.length) {
    579        this.items.delete(id);
    580      }
    581      if (this.items.size == 0) {
    582        this.label.setAttribute("empty", "true");
    583      }
    584      deleted.remove(items);
    585    } else if (!id) {
    586      this.destroy();
    587    }
    588  }
    589 
    590  /**
    591   * If this item is to be selected, then selected and expands the item.
    592   * Otherwise, if a child item is to be selected, just expands this item.
    593   *
    594   * @param {Array} items
    595   *        Ids of items leading up to the item to be selected.
    596   */
    597  setSelectedItem(items) {
    598    if (!items[this.level]) {
    599      this.label.classList.add("theme-selected");
    600      this.label.setAttribute("expanded", "true");
    601      return this.label;
    602    }
    603    if (this.items.has(items[this.level])) {
    604      const label = this.items.get(items[this.level]).setSelectedItem(items);
    605      if (label && this.label) {
    606        this.label.setAttribute("expanded", true);
    607      }
    608      return label;
    609    }
    610    return null;
    611  }
    612 
    613  /**
    614   * Collapses this item and all of its sub tree items
    615   */
    616  collapseAll() {
    617    if (this.label) {
    618      this.label.removeAttribute("expanded");
    619    }
    620    for (const child of this.items.values()) {
    621      child.collapseAll();
    622    }
    623  }
    624 
    625  /**
    626   * Expands this item and all of its sub tree items
    627   */
    628  expandAll() {
    629    if (this.label) {
    630      this.label.setAttribute("expanded", "true");
    631    }
    632    for (const child of this.items.values()) {
    633      child.expandAll();
    634    }
    635  }
    636 
    637  destroy() {
    638    this.children.remove();
    639    this.node.remove();
    640    this.label = null;
    641    this.items = null;
    642    this.children = null;
    643  }
    644 }