/* -*- 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 Services = require("Services") const HTML_NS = "http://www.w3.org/1999/xhtml"; const EventEmitter = require("devtools/toolkit/event-emitter"); /** * 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. */ 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.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} id * An array of ids leading upto the selected item */ set selectedItem(id) { if (this._selectedLabel) { this._selectedLabel.classList.remove("theme-selected"); } let currentSelected = this._selectedLabel; if (id == -1) { this._selectedLabel = this._selectedItem = null; return; } if (!Array.isArray(id)) { return; } this._selectedLabel = this.root.setSelectedItem(id); if (!this._selectedLabel) { this._selectedItem = null; } else { if (currentSelected != this._selectedLabel) { this.ensureSelectedVisible(); } this._selectedItem = JSON.parse(this._selectedLabel.parentNode.getAttribute("data-id")); } }, /** * 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); this._parent.appendChild(this.root.children); this.root.children.addEventListener("click", 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) { this._selectedLabel.classList.remove("theme-selected"); } if (this._selectedLabel != target) { let ids = target.parentNode.getAttribute("data-id"); this._selectedItem = JSON.parse(ids); this.emit("select", this._selectedItem, this.attachments.get(ids)); this._selectedLabel = target; } target.classList.add("theme-selected"); }, /** * Keypress handler for this tree. Used to select next and previous visible * items, as well as collapsing and expanding any item. */ onKeypress: function(event) { let currentSelected = this._selectedLabel; switch(event.keyCode) { case event.DOM_VK_UP: this.selectPreviousItem(); break; case event.DOM_VK_DOWN: this.selectNextItem(); break; case event.DOM_VK_RIGHT: if (this._selectedLabel.hasAttribute("expanded")) { this.selectNextItem(); } else { this._selectedLabel.setAttribute("expanded", "true"); } break; case event.DOM_VK_LEFT: if (this._selectedLabel.hasAttribute("expanded") && !this._selectedLabel.hasAttribute("empty")) { this._selectedLabel.removeAttribute("expanded"); } else { this.selectPreviousItem(); } break; default: return; } event.preventDefault(); if (this._selectedLabel != currentSelected) { let ids = JSON.stringify(this._selectedItem); this.emit("select", this._selectedItem, this.attachments.get(ids)); this.ensureSelectedVisible(); } }, /** * 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); } 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; } };