devtools/client/shared/widgets/AbstractTreeItem.jsm
author Kris Maglione <maglione.k@gmail.com>
Mon, 29 Jan 2018 15:20:18 -0800
changeset 454011 e6a7b5e11ba856ee3535f76c6bcca17ea29e3d5f
parent 453889 c6e0fe339cb1e7c0c029e68be5332e52f53ab6b7
child 455106 b38d59f71915f78922b46a7c7bc65a48488c45f1
permissions -rw-r--r--
Bug 1431533: Part 5a - Auto-rewrite code to use ChromeUtils import methods. r=florian This was done using the following script: https://bitbucket.org/kmaglione/m-c-rewrites/src/37e3803c7a23385bab5ea077ba8692bcfe1608fc/processors/chromeutils-import.jsm MozReview-Commit-ID: 1Nc3XDu0wGl

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";

const { interfaces: Ci, utils: Cu } = Components;

const { require, loader } = Cu.import("resource://devtools/shared/Loader.jsm", {});
const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm");
const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
const { KeyCodes } = require("devtools/client/shared/keycodes");

loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/old-event-emitter");

ChromeUtils.defineModuleGetter(this, "console",
  "resource://gre/modules/Console.jsm");

this.EXPORTED_SYMBOLS = ["AbstractTreeItem"];

/**
 * A very generic and low-level tree view implementation. It is not intended
 * to be used alone, but as a base class that you can extend to build your
 * own custom implementation.
 *
 * Language:
 *   - An "item" is an instance of an AbstractTreeItem.
 *   - An "element" or "node" is an nsIDOMNode.
 *
 * The following events are emitted by this tree, always from the root item,
 * with the first argument pointing to the affected child item:
 *   - "expand": when an item is expanded in the tree
 *   - "collapse": when an item is collapsed in the tree
 *   - "focus": when an item is selected in the tree
 *
 * For example, you can extend this abstract class like this:
 *
 * function MyCustomTreeItem(dataSrc, properties) {
 *   AbstractTreeItem.call(this, properties);
 *   this.itemDataSrc = dataSrc;
 * }
 *
 * MyCustomTreeItem.prototype = extend(AbstractTreeItem.prototype, {
 *   _displaySelf: function(document, arrowNode) {
 *     let node = document.createElement("hbox");
 *     ...
 *     // Append the provided arrow node wherever you want.
 *     node.appendChild(arrowNode);
 *     ...
 *     // Use `this.itemDataSrc` to customize the tree item and
 *     // `this.level` to calculate the indentation.
 *     node.style.marginInlineStart = (this.level * 10) + "px";
 *     node.appendChild(document.createTextNode(this.itemDataSrc.label));
 *     ...
 *     return node;
 *   },
 *   _populateSelf: function(children) {
 *     ...
 *     // Use `this.itemDataSrc` to get the data source for the child items.
 *     let someChildDataSrc = this.itemDataSrc.children[0];
 *     ...
 *     children.push(new MyCustomTreeItem(someChildDataSrc, {
 *       parent: this,
 *       level: this.level + 1
 *     }));
 *     ...
 *   }
 * });
 *
 * And then you could use it like this:
 *
 * let dataSrc = {
 *   label: "root",
 *   children: [{
 *     label: "foo",
 *     children: []
 *   }, {
 *     label: "bar",
 *     children: [{
 *       label: "baz",
 *       children: []
 *     }]
 *   }]
 * };
 * let root = new MyCustomTreeItem(dataSrc, { parent: null });
 * root.attachTo(nsIDOMNode);
 * root.expand();
 *
 * The following tree view will be generated (after expanding all nodes):
 * ▼ root
 *   ▶ foo
 *   ▼ bar
 *     ▶ baz
 *
 * The way the data source is implemented is completely up to you. There's
 * no assumptions made and you can use it however you like inside the
 * `_displaySelf` and `populateSelf` methods. If you need to add children to a
 * node at a later date, you just need to modify the data source:
 *
 * dataSrc[...path-to-foo...].children.push({
 *   label: "lazily-added-node"
 *   children: []
 * });
 *
 * The existing tree view will be modified like so (after expanding `foo`):
 * ▼ root
 *   ▼ foo
 *     ▶ lazily-added-node
 *   ▼ bar
 *     ▶ baz
 *
 * Everything else is taken care of automagically!
 *
 * @param AbstractTreeItem parent
 *        The parent tree item. Should be null for root items.
 * @param number level
 *        The indentation level in the tree. The root item is at level 0.
 */
function AbstractTreeItem({ parent, level }) {
  this._rootItem = parent ? parent._rootItem : this;
  this._parentItem = parent;
  this._level = level || 0;
  this._childTreeItems = [];

  // Events are always propagated through the root item. Decorating every
  // tree item as an event emitter is a very costly operation.
  if (this == this._rootItem) {
    EventEmitter.decorate(this);
  }
}
this.AbstractTreeItem = AbstractTreeItem;

AbstractTreeItem.prototype = {
  _containerNode: null,
  _targetNode: null,
  _arrowNode: null,
  _constructed: false,
  _populated: false,
  _expanded: false,

  /**
   * Optionally, trees may be allowed to automatically expand a few levels deep
   * to avoid initially displaying a completely collapsed tree.
   */
  autoExpandDepth: 0,

  /**
   * Creates the view for this tree item. Implement this method in the
   * inheriting classes to create the child node displayed in the tree.
   * Use `this.level` and the provided `arrowNode` as you see fit.
   *
   * @param nsIDOMNode document
   * @param nsIDOMNode arrowNode
   * @return nsIDOMNode
   */
  _displaySelf: function (document, arrowNode) {
    throw new Error(
      "The `_displaySelf` method needs to be implemented by inheriting classes.");
  },

  /**
   * Populates this tree item with child items, whenever it's expanded.
   * Implement this method in the inheriting classes to fill the provided
   * `children` array with AbstractTreeItem instances, which will then be
   * magically handled by this tree item.
   *
   * @param array:AbstractTreeItem children
   */
  _populateSelf: function (children) {
    throw new Error(
      "The `_populateSelf` method needs to be implemented by inheriting classes.");
  },

  /**
   * Gets the this tree's owner document.
   * @return Document
   */
  get document() {
    return this._containerNode.ownerDocument;
  },

  /**
   * Gets the root item of this tree.
   * @return AbstractTreeItem
   */
  get root() {
    return this._rootItem;
  },

  /**
   * Gets the parent of this tree item.
   * @return AbstractTreeItem
   */
  get parent() {
    return this._parentItem;
  },

  /**
   * Gets the indentation level of this tree item.
   */
  get level() {
    return this._level;
  },

  /**
   * Gets the element displaying this tree item.
   */
  get target() {
    return this._targetNode;
  },

  /**
   * Gets the element containing all tree items.
   * @return nsIDOMNode
   */
  get container() {
    return this._containerNode;
  },

  /**
   * Returns whether or not this item is populated in the tree.
   * Collapsed items can still be populated.
   * @return boolean
   */
  get populated() {
    return this._populated;
  },

  /**
   * Returns whether or not this item is expanded in the tree.
   * Expanded items with no children aren't consudered `populated`.
   * @return boolean
   */
  get expanded() {
    return this._expanded;
  },

  /**
   * Gets the bounds for this tree's container without flushing.
   * @return object
   */
  get bounds() {
    let win = this.document.defaultView;
    let utils = win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindowUtils);
    return utils.getBoundsWithoutFlushing(this._containerNode);
  },

  /**
   * Creates and appends this tree item to the specified parent element.
   *
   * @param nsIDOMNode containerNode
   *        The parent element for this tree item (and every other tree item).
   * @param nsIDOMNode fragmentNode [optional]
   *        An optional document fragment temporarily holding this tree item in
   *        the current batch. Defaults to the `containerNode`.
   * @param nsIDOMNode beforeNode [optional]
   *        An optional child element which should succeed this tree item.
   */
  attachTo: function (containerNode, fragmentNode = containerNode, beforeNode = null) {
    this._containerNode = containerNode;
    this._constructTargetNode();

    if (beforeNode) {
      fragmentNode.insertBefore(this._targetNode, beforeNode);
    } else {
      fragmentNode.appendChild(this._targetNode);
    }

    if (this._level < this.autoExpandDepth) {
      this.expand();
    }
  },

  /**
   * Permanently removes this tree item (and all subsequent children) from the
   * parent container.
   */
  remove: function () {
    this._targetNode.remove();
    this._hideChildren();
    this._childTreeItems.length = 0;
  },

  /**
   * Focuses this item in the tree.
   */
  focus: function () {
    this._targetNode.focus();
  },

  /**
   * Expands this item in the tree.
   */
  expand: function () {
    if (this._expanded) {
      return;
    }
    this._expanded = true;
    this._arrowNode.setAttribute("open", "");
    this._targetNode.setAttribute("expanded", "");
    this._toggleChildren(true);
    this._rootItem.emit("expand", this);
  },

  /**
   * Collapses this item in the tree.
   */
  collapse: function () {
    if (!this._expanded) {
      return;
    }
    this._expanded = false;
    this._arrowNode.removeAttribute("open");
    this._targetNode.removeAttribute("expanded", "");
    this._toggleChildren(false);
    this._rootItem.emit("collapse", this);
  },

  /**
   * Returns the child item at the specified index.
   *
   * @param number index
   * @return AbstractTreeItem
   */
  getChild: function (index = 0) {
    return this._childTreeItems[index];
  },

  /**
   * Calls the provided function on all the descendants of this item.
   * If this item was never expanded, then no descendents exist yet.
   * @param function cb
   */
  traverse: function (cb) {
    for (let child of this._childTreeItems) {
      cb(child);
      child.bfs();
    }
  },

  /**
   * Calls the provided function on all descendants of this item until
   * a truthy value is returned by the predicate.
   * @param function predicate
   * @return AbstractTreeItem
   */
  find: function (predicate) {
    for (let child of this._childTreeItems) {
      if (predicate(child) || child.find(predicate)) {
        return child;
      }
    }
    return null;
  },

  /**
   * Shows or hides all the children of this item in the tree. If neessary,
   * populates this item with children.
   *
   * @param boolean visible
   *        True if the children should be visible, false otherwise.
   */
  _toggleChildren: function (visible) {
    if (visible) {
      if (!this._populated) {
        this._populateSelf(this._childTreeItems);
        this._populated = this._childTreeItems.length > 0;
      }
      this._showChildren();
    } else {
      this._hideChildren();
    }
  },

  /**
   * Shows all children of this item in the tree.
   */
  _showChildren: function () {
    // If this is the root item and we're not expanding any child nodes,
    // it is safe to append everything at once.
    if (this == this._rootItem && this.autoExpandDepth == 0) {
      this._appendChildrenBatch();
    }
    // Otherwise, append the child items and their descendants successively;
    // if not, the tree will become garbled and nodes will intertwine,
    // since all the tree items are sharing a single container node.
    else {
      this._appendChildrenSuccessive();
    }
  },

  /**
   * Hides all children of this item in the tree.
   */
  _hideChildren: function () {
    for (let item of this._childTreeItems) {
      item._targetNode.remove();
      item._hideChildren();
    }
  },

  /**
   * Appends all children in a single batch.
   * This only works properly for root nodes when no child nodes will expand.
   */
  _appendChildrenBatch: function () {
    if (this._fragment === undefined) {
      this._fragment = this.document.createDocumentFragment();
    }

    let childTreeItems = this._childTreeItems;

    for (let i = 0, len = childTreeItems.length; i < len; i++) {
      childTreeItems[i].attachTo(this._containerNode, this._fragment);
    }

    this._containerNode.appendChild(this._fragment);
  },

  /**
   * Appends all children successively.
   */
  _appendChildrenSuccessive: function () {
    let childTreeItems = this._childTreeItems;
    let expandedChildTreeItems = childTreeItems.filter(e => e._expanded);
    let nextNode = this._getSiblingAtDelta(1);

    for (let i = 0, len = childTreeItems.length; i < len; i++) {
      childTreeItems[i].attachTo(this._containerNode, undefined, nextNode);
    }
    for (let i = 0, len = expandedChildTreeItems.length; i < len; i++) {
      expandedChildTreeItems[i]._showChildren();
    }
  },

  /**
   * Constructs and stores the target node displaying this tree item.
   */
  _constructTargetNode: function () {
    if (this._constructed) {
      return;
    }
    this._onArrowClick = this._onArrowClick.bind(this);
    this._onClick = this._onClick.bind(this);
    this._onDoubleClick = this._onDoubleClick.bind(this);
    this._onKeyPress = this._onKeyPress.bind(this);
    this._onFocus = this._onFocus.bind(this);
    this._onBlur = this._onBlur.bind(this);

    let document = this.document;

    let arrowNode = this._arrowNode = document.createElement("hbox");
    arrowNode.className = "arrow theme-twisty";
    arrowNode.addEventListener("mousedown", this._onArrowClick);

    let targetNode = this._targetNode = this._displaySelf(document, arrowNode);
    targetNode.style.MozUserFocus = "normal";

    targetNode.addEventListener("mousedown", this._onClick);
    targetNode.addEventListener("dblclick", this._onDoubleClick);
    targetNode.addEventListener("keypress", this._onKeyPress);
    targetNode.addEventListener("focus", this._onFocus);
    targetNode.addEventListener("blur", this._onBlur);

    this._constructed = true;
  },

  /**
   * Gets the element displaying an item in the tree at the specified offset
   * relative to this item.
   *
   * @param number delta
   *        The offset from this item to the target item.
   * @return nsIDOMNode
   *         The element displaying the target item at the specified offset.
   */
  _getSiblingAtDelta: function (delta) {
    let childNodes = this._containerNode.childNodes;
    let indexOfSelf = Array.indexOf(childNodes, this._targetNode);
    if (indexOfSelf + delta >= 0) {
      return childNodes[indexOfSelf + delta];
    }
    return undefined;
  },

  _getNodesPerPageSize: function() {
    let childNodes = this._containerNode.childNodes;
    let nodeHeight = this._getHeight(childNodes[childNodes.length - 1]);
    let containerHeight = this.bounds.height;
    return Math.ceil(containerHeight / nodeHeight);
  },

  _getHeight: function(elem) {
    let win = this.document.defaultView;
    let utils = win.QueryInterface(Ci.nsIInterfaceRequestor)
                   .getInterface(Ci.nsIDOMWindowUtils);
    return utils.getBoundsWithoutFlushing(elem).height;
  },

  /**
   * Focuses the first item in this tree.
   */
  _focusFirstNode: function () {
    let childNodes = this._containerNode.childNodes;
    // The root node of the tree may be hidden in practice, so uses for-loop
    // here to find the next visible node.
    for (let i = 0; i < childNodes.length; i++) {
      // The height will be 0 if an element is invisible.
      if (this._getHeight(childNodes[i])) {
        childNodes[i].focus();
        return;
      }
    }
  },

  /**
   * Focuses the last item in this tree.
   */
  _focusLastNode: function () {
    let childNodes = this._containerNode.childNodes;
    childNodes[childNodes.length - 1].focus();
  },

  /**
   * Focuses the next item in this tree.
   */
  _focusNextNode: function () {
    let nextElement = this._getSiblingAtDelta(1);
    if (nextElement) nextElement.focus(); // nsIDOMNode
  },

  /**
   * Focuses the previous item in this tree.
   */
  _focusPrevNode: function () {
    let prevElement = this._getSiblingAtDelta(-1);
    if (prevElement) prevElement.focus(); // nsIDOMNode
  },

  /**
   * Focuses the parent item in this tree.
   *
   * The parent item is not always the previous item, because any tree item
   * may have multiple children.
   */
  _focusParentNode: function () {
    let parentItem = this._parentItem;
    if (parentItem) parentItem.focus(); // AbstractTreeItem
  },

  /**
   * Handler for the "click" event on the arrow node of this tree item.
   */
  _onArrowClick: function (e) {
    if (!this._expanded) {
      this.expand();
    } else {
      this.collapse();
    }
  },

  /**
   * Handler for the "click" event on the element displaying this tree item.
   */
  _onClick: function (e) {
    e.stopPropagation();
    this.focus();
  },

  /**
   * Handler for the "dblclick" event on the element displaying this tree item.
   */
  _onDoubleClick: function (e) {
    // Ignore dblclick on the arrow as it has already recived and handled two
    // click events.
    if (!e.target.classList.contains("arrow")) {
      this._onArrowClick(e);
    }
    this.focus();
  },

  /**
   * Handler for the "keypress" event on the element displaying this tree item.
   */
  _onKeyPress: function (e) {
    // Prevent scrolling when pressing navigation keys.
    ViewHelpers.preventScrolling(e);

    switch (e.keyCode) {
      case KeyCodes.DOM_VK_UP:
        this._focusPrevNode();
        return;

      case KeyCodes.DOM_VK_DOWN:
        this._focusNextNode();
        return;

      case KeyCodes.DOM_VK_LEFT:
        if (this._expanded && this._populated) {
          this.collapse();
        } else {
          this._focusParentNode();
        }
        return;

      case KeyCodes.DOM_VK_RIGHT:
        if (!this._expanded) {
          this.expand();
        } else {
          this._focusNextNode();
        }
        return;

      case KeyCodes.DOM_VK_PAGE_UP:
        let pageUpElement =
          this._getSiblingAtDelta(-this._getNodesPerPageSize());
        // There's a chance that the root node is hidden. In this case, its
        // height will be 0.
        if (pageUpElement && this._getHeight(pageUpElement)) {
          pageUpElement.focus();
        } else {
          this._focusFirstNode();
        }
        return;

      case KeyCodes.DOM_VK_PAGE_DOWN:
        let pageDownElement =
          this._getSiblingAtDelta(this._getNodesPerPageSize());
        if (pageDownElement) {
          pageDownElement.focus();
        } else {
          this._focusLastNode();
        }
        return;

      case KeyCodes.DOM_VK_HOME:
        this._focusFirstNode();
        return;

      case KeyCodes.DOM_VK_END:
        this._focusLastNode();
        return;
    }
  },

  /**
   * Handler for the "focus" event on the element displaying this tree item.
   */
  _onFocus: function (e) {
    this._rootItem.emit("focus", this);
  },

  /**
   * Handler for the "blur" event on the element displaying this tree item.
   */
  _onBlur: function (e) {
    this._rootItem.emit("blur", this);
  }
};