diff options
Diffstat (limited to 'devtools/client/shared/widgets/TreeWidget.js')
-rw-r--r-- | devtools/client/shared/widgets/TreeWidget.js | 605 |
1 files changed, 605 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/TreeWidget.js b/devtools/client/shared/widgets/TreeWidget.js new file mode 100644 index 000000000..1f766cc6b --- /dev/null +++ b/devtools/client/shared/widgets/TreeWidget.js @@ -0,0 +1,605 @@ +/* -*- 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 HTML_NS = "http://www.w3.org/1999/xhtml"; + +const EventEmitter = require("devtools/shared/event-emitter"); +const {KeyCodes} = require("devtools/client/shared/keycodes"); + +/** + * A tree widget with keyboard navigation and collapsable structure. + * + * @param {nsIDOMNode} node + * The container element for the tree widget. + * @param {Object} options + * - emptyText {string}: text to display when no entries in the table. + * - defaultType {string}: The default type of the tree items. For ex. + * 'js' + * - sorted {boolean}: Defaults to true. If true, tree items are kept in + * lexical order. If false, items will be kept in insertion order. + * - contextMenuId {string}: ID of context menu to be displayed on + * tree items. + */ +function TreeWidget(node, options = {}) { + EventEmitter.decorate(this); + + this.document = node.ownerDocument; + this.window = this.document.defaultView; + this._parent = node; + + this.emptyText = options.emptyText || ""; + this.defaultType = options.defaultType; + this.sorted = options.sorted !== false; + this.contextMenuId = options.contextMenuId; + + this.setupRoot(); + + this.placeholder = this.document.createElementNS(HTML_NS, "label"); + this.placeholder.className = "tree-widget-empty-text"; + this._parent.appendChild(this.placeholder); + + if (this.emptyText) { + this.setPlaceholderText(this.emptyText); + } + // A map to hold all the passed attachment to each leaf in the tree. + this.attachments = new Map(); +} + +TreeWidget.prototype = { + + _selectedLabel: null, + _selectedItem: null, + + /** + * Select any node in the tree. + * + * @param {array} ids + * An array of ids leading upto the selected item + */ + set selectedItem(ids) { + if (this._selectedLabel) { + this._selectedLabel.classList.remove("theme-selected"); + } + let currentSelected = this._selectedLabel; + if (ids == -1) { + this._selectedLabel = this._selectedItem = null; + return; + } + if (!Array.isArray(ids)) { + return; + } + this._selectedLabel = this.root.setSelectedItem(ids); + if (!this._selectedLabel) { + this._selectedItem = null; + } else { + if (currentSelected != this._selectedLabel) { + this.ensureSelectedVisible(); + } + this._selectedItem = ids; + this.emit("select", this._selectedItem, + this.attachments.get(JSON.stringify(ids))); + } + }, + + /** + * Gets the selected item in the tree. + * + * @return {array} + * An array of ids leading upto the selected item + */ + get selectedItem() { + return this._selectedItem; + }, + + /** + * Returns if the passed array corresponds to the selected item in the tree. + * + * @return {array} + * An array of ids leading upto the requested item + */ + isSelected: function (item) { + if (!this._selectedItem || this._selectedItem.length != item.length) { + return false; + } + + for (let i = 0; i < this._selectedItem.length; i++) { + if (this._selectedItem[i] != item[i]) { + return false; + } + } + + return true; + }, + + destroy: function () { + this.root.remove(); + this.root = null; + }, + + /** + * Sets up the root container of the TreeWidget. + */ + setupRoot: function () { + this.root = new TreeItem(this.document); + if (this.contextMenuId) { + this.root.children.addEventListener("contextmenu", (event) => { + let menu = this.document.getElementById(this.contextMenuId); + menu.openPopupAtScreen(event.screenX, event.screenY, true); + }); + } + + this._parent.appendChild(this.root.children); + + this.root.children.addEventListener("mousedown", e => this.onClick(e)); + this.root.children.addEventListener("keypress", e => this.onKeypress(e)); + }, + + /** + * Sets the text to be shown when no node is present in the tree + */ + setPlaceholderText: function (text) { + this.placeholder.textContent = text; + }, + + /** + * Select any node in the tree. + * + * @param {array} id + * An array of ids leading upto the selected item + */ + selectItem: function (id) { + this.selectedItem = id; + }, + + /** + * Selects the next visible item in the tree. + */ + selectNextItem: function () { + let next = this.getNextVisibleItem(); + if (next) { + this.selectedItem = next; + } + }, + + /** + * Selects the previos visible item in the tree + */ + selectPreviousItem: function () { + let prev = this.getPreviousVisibleItem(); + if (prev) { + this.selectedItem = prev; + } + }, + + /** + * Returns the next visible item in the tree + */ + getNextVisibleItem: function () { + let node = this._selectedLabel; + if (node.hasAttribute("expanded") && node.nextSibling.firstChild) { + return JSON.parse(node.nextSibling.firstChild.getAttribute("data-id")); + } + node = node.parentNode; + if (node.nextSibling) { + return JSON.parse(node.nextSibling.getAttribute("data-id")); + } + node = node.parentNode; + while (node.parentNode && node != this.root.children) { + if (node.parentNode && node.parentNode.nextSibling) { + return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id")); + } + node = node.parentNode; + } + return null; + }, + + /** + * Returns the previous visible item in the tree + */ + getPreviousVisibleItem: function () { + let node = this._selectedLabel.parentNode; + if (node.previousSibling) { + node = node.previousSibling.firstChild; + while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) { + if (!node.nextSibling.lastChild) { + break; + } + node = node.nextSibling.lastChild.firstChild; + } + return JSON.parse(node.parentNode.getAttribute("data-id")); + } + node = node.parentNode; + if (node.parentNode && node != this.root.children) { + node = node.parentNode; + while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) { + if (!node.nextSibling.firstChild) { + break; + } + node = node.nextSibling.firstChild.firstChild; + } + return JSON.parse(node.getAttribute("data-id")); + } + return null; + }, + + clearSelection: function () { + this.selectedItem = -1; + }, + + /** + * Adds an item in the tree. The item can be added as a child to any node in + * the tree. The method will also create any subnode not present in the + * process. + * + * @param {[string|object]} items + * An array of either string or objects where each increasing index + * represents an item corresponding to an equivalent depth in the tree. + * Each array element can be either just a string with the value as the + * id of of that item as well as the display value, or it can be an + * object with the following propeties: + * - id {string} The id of the item + * - label {string} The display value of the item + * - node {DOMNode} The dom node if you want to insert some custom + * element as the item. The label property is not used in this + * case + * - attachment {object} Any object to be associated with this item. + * - type {string} The type of this particular item. If this is null, + * then defaultType will be used. + * For example, if items = ["foo", "bar", { id: "id1", label: "baz" }] + * and the tree is empty, then the following hierarchy will be created + * in the tree: + * foo + * └ bar + * └ baz + * Passing the string id instead of the complete object helps when you + * are simply adding children to an already existing node and you know + * its id. + */ + add: function (items) { + this.root.add(items, this.defaultType, this.sorted); + for (let i = 0; i < items.length; i++) { + if (items[i].attachment) { + this.attachments.set(JSON.stringify( + items.slice(0, i + 1).map(item => item.id || item) + ), items[i].attachment); + } + } + // Empty the empty-tree-text + this.setPlaceholderText(""); + }, + + /** + * Removes the specified item and all of its child items from the tree. + * + * @param {array} item + * The array of ids leading up to the item. + */ + remove: function (item) { + this.root.remove(item); + this.attachments.delete(JSON.stringify(item)); + // Display the empty tree text + if (this.root.items.size == 0 && this.emptyText) { + this.setPlaceholderText(this.emptyText); + } + }, + + /** + * Removes all of the child nodes from this tree. + */ + clear: function () { + this.root.remove(); + this.setupRoot(); + this.attachments.clear(); + if (this.emptyText) { + this.setPlaceholderText(this.emptyText); + } + }, + + /** + * Expands the tree completely + */ + expandAll: function () { + this.root.expandAll(); + }, + + /** + * Collapses the tree completely + */ + collapseAll: function () { + this.root.collapseAll(); + }, + + /** + * Click handler for the tree. Used to select, open and close the tree nodes. + */ + onClick: function (event) { + let target = event.originalTarget; + while (target && !target.classList.contains("tree-widget-item")) { + if (target == this.root.children) { + return; + } + target = target.parentNode; + } + if (!target) { + return; + } + + if (target.hasAttribute("expanded")) { + target.removeAttribute("expanded"); + } else { + target.setAttribute("expanded", "true"); + } + + if (this._selectedLabel != target) { + let ids = target.parentNode.getAttribute("data-id"); + this.selectedItem = JSON.parse(ids); + } + }, + + /** + * Keypress handler for this tree. Used to select next and previous visible + * items, as well as collapsing and expanding any item. + */ + onKeypress: function (event) { + switch (event.keyCode) { + case KeyCodes.DOM_VK_UP: + this.selectPreviousItem(); + break; + + case KeyCodes.DOM_VK_DOWN: + this.selectNextItem(); + break; + + case KeyCodes.DOM_VK_RIGHT: + if (this._selectedLabel.hasAttribute("expanded")) { + this.selectNextItem(); + } else { + this._selectedLabel.setAttribute("expanded", "true"); + } + break; + + case KeyCodes.DOM_VK_LEFT: + if (this._selectedLabel.hasAttribute("expanded") && + !this._selectedLabel.hasAttribute("empty")) { + this._selectedLabel.removeAttribute("expanded"); + } else { + this.selectPreviousItem(); + } + break; + + default: return; + } + event.preventDefault(); + }, + + /** + * Scrolls the viewport of the tree so that the selected item is always + * visible. + */ + ensureSelectedVisible: function () { + let {top, bottom} = this._selectedLabel.getBoundingClientRect(); + let height = this.root.children.parentNode.clientHeight; + if (top < 0) { + this._selectedLabel.scrollIntoView(); + } else if (bottom > height) { + this._selectedLabel.scrollIntoView(false); + } + } +}; + +module.exports.TreeWidget = TreeWidget; + +/** + * Any item in the tree. This can be an empty leaf node also. + * + * @param {HTMLDocument} document + * The document element used for creating new nodes. + * @param {TreeItem} parent + * The parent item for this item. + * @param {string|DOMElement} label + * Either the dom node to be used as the item, or the string to be + * displayed for this node in the tree + * @param {string} type + * The type of the current node. For ex. "js" + */ +function TreeItem(document, parent, label, type) { + this.document = document; + this.node = this.document.createElementNS(HTML_NS, "li"); + this.node.setAttribute("tabindex", "0"); + this.isRoot = !parent; + this.parent = parent; + if (this.parent) { + this.level = this.parent.level + 1; + } + if (label) { + this.label = this.document.createElementNS(HTML_NS, "div"); + this.label.setAttribute("empty", "true"); + this.label.setAttribute("level", this.level); + this.label.className = "tree-widget-item"; + if (type) { + this.label.setAttribute("type", type); + } + if (typeof label == "string") { + this.label.textContent = label; + } else { + this.label.appendChild(label); + } + this.node.appendChild(this.label); + } + this.children = this.document.createElementNS(HTML_NS, "ul"); + if (this.isRoot) { + this.children.className = "tree-widget-container"; + } else { + this.children.className = "tree-widget-children"; + } + this.node.appendChild(this.children); + this.items = new Map(); +} + +TreeItem.prototype = { + + items: null, + + isSelected: false, + + expanded: false, + + isRoot: false, + + parent: null, + + children: null, + + level: 0, + + /** + * Adds the item to the sub tree contained by this node. The item to be + * inserted can be a direct child of this node, or further down the tree. + * + * @param {array} items + * Same as TreeWidget.add method's argument + * @param {string} defaultType + * The default type of the item to be used when items[i].type is null + * @param {boolean} sorted + * true if the tree items are inserted in a lexically sorted manner. + * Otherwise, false if the item are to be appended to their parent. + */ + add: function (items, defaultType, sorted) { + if (items.length == this.level) { + // This is the exit condition of recursive TreeItem.add calls + return; + } + // Get the id and label corresponding to this level inside the tree. + let id = items[this.level].id || items[this.level]; + if (this.items.has(id)) { + // An item with same id already exists, thus calling the add method of + // that child to add the passed node at correct position. + this.items.get(id).add(items, defaultType, sorted); + return; + } + // No item with the id `id` exists, so we create one and call the add + // method of that item. + // The display string of the item can be the label, the id, or the item + // itself if its a plain string. + let label = items[this.level].label || + items[this.level].id || + items[this.level]; + let node = items[this.level].node; + if (node) { + // The item is supposed to be a DOMNode, so we fetch the textContent in + // order to find the correct sorted location of this new item. + label = node.textContent; + } + let treeItem = new TreeItem(this.document, this, node || label, + items[this.level].type || defaultType); + + treeItem.add(items, defaultType, sorted); + treeItem.node.setAttribute("data-id", JSON.stringify( + items.slice(0, this.level + 1).map(item => item.id || item) + )); + + if (sorted) { + // Inserting this newly created item at correct position + let nextSibling = [...this.items.values()].find(child => { + return child.label.textContent >= label; + }); + + if (nextSibling) { + this.children.insertBefore(treeItem.node, nextSibling.node); + } else { + this.children.appendChild(treeItem.node); + } + } else { + this.children.appendChild(treeItem.node); + } + + if (this.label) { + this.label.removeAttribute("empty"); + } + this.items.set(id, treeItem); + }, + + /** + * If this item is to be removed, then removes this item and thus all of its + * subtree. Otherwise, call the remove method of appropriate child. This + * recursive method goes on till we have reached the end of the branch or the + * current item is to be removed. + * + * @param {array} items + * Ids of items leading up to the item to be removed. + */ + remove: function (items = []) { + let id = items.shift(); + if (id && this.items.has(id)) { + let deleted = this.items.get(id); + if (!items.length) { + this.items.delete(id); + } + if (this.items.size == 0) { + this.label.setAttribute("empty", "true"); + } + deleted.remove(items); + } else if (!id) { + this.destroy(); + } + }, + + /** + * If this item is to be selected, then selected and expands the item. + * Otherwise, if a child item is to be selected, just expands this item. + * + * @param {array} items + * Ids of items leading up to the item to be selected. + */ + setSelectedItem: function (items) { + if (!items[this.level]) { + this.label.classList.add("theme-selected"); + this.label.setAttribute("expanded", "true"); + return this.label; + } + if (this.items.has(items[this.level])) { + let label = this.items.get(items[this.level]).setSelectedItem(items); + if (label && this.label) { + this.label.setAttribute("expanded", true); + } + return label; + } + return null; + }, + + /** + * Collapses this item and all of its sub tree items + */ + collapseAll: function () { + if (this.label) { + this.label.removeAttribute("expanded"); + } + for (let child of this.items.values()) { + child.collapseAll(); + } + }, + + /** + * Expands this item and all of its sub tree items + */ + expandAll: function () { + if (this.label) { + this.label.setAttribute("expanded", "true"); + } + for (let child of this.items.values()) { + child.expandAll(); + } + }, + + destroy: function () { + this.children.remove(); + this.node.remove(); + this.label = null; + this.items = null; + this.children = null; + } +}; |