summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/widgets/AbstractTreeItem.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/widgets/AbstractTreeItem.jsm')
-rw-r--r--devtools/client/shared/widgets/AbstractTreeItem.jsm661
1 files changed, 661 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/AbstractTreeItem.jsm b/devtools/client/shared/widgets/AbstractTreeItem.jsm
new file mode 100644
index 000000000..541ab6777
--- /dev/null
+++ b/devtools/client/shared/widgets/AbstractTreeItem.jsm
@@ -0,0 +1,661 @@
+/* -*- 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 } = 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");
+
+XPCOMUtils.defineLazyModuleGetter(this, "EventEmitter",
+ "resource://devtools/shared/event-emitter.js");
+
+XPCOMUtils.defineLazyModuleGetter(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 = Heritage.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);
+ }
+};