Source code
Revision control
Copy as Markdown
Other Tools
/* 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
"use strict";
const EventEmitter = require("resource://devtools/shared/event-emitter.js");
const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js");
/**
* A tree widget with keyboard navigation and collapsable structure.
*
* @param {Node} 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");
}
const 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(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() {
this.root.remove();
this.root = null;
},
/**
* Sets up the root container of the TreeWidget.
*/
setupRoot() {
this.root = new TreeItem(this.document);
if (this.contextMenuId) {
this.root.children.addEventListener("contextmenu", event => {
// Call stopPropagation() and preventDefault() here so that avoid to show default
event.stopPropagation();
event.preventDefault();
const 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("keydown", e => this.onKeydown(e));
},
/**
* Sets the text to be shown when no node is present in the tree.
* The placeholder will be hidden if text is empty.
*/
setPlaceholderText(text) {
this.placeholder.textContent = text;
if (text) {
this.placeholder.removeAttribute("hidden");
} else {
this.placeholder.setAttribute("hidden", "true");
}
},
/**
* Select any node in the tree.
*
* @param {array} id
* An array of ids leading upto the selected item
*/
selectItem(id) {
this.selectedItem = id;
},
/**
* Selects the next visible item in the tree.
*/
selectNextItem() {
const next = this.getNextVisibleItem();
if (next) {
this.selectedItem = next;
}
},
/**
* Selects the previos visible item in the tree
*/
selectPreviousItem() {
const prev = this.getPreviousVisibleItem();
if (prev) {
this.selectedItem = prev;
}
},
/**
* Returns the next visible item in the tree
*/
getNextVisibleItem() {
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?.nextSibling) {
return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id"));
}
node = node.parentNode;
}
return null;
},
/**
* Returns the previous visible item in the tree
*/
getPreviousVisibleItem() {
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() {
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(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("");
},
/**
* Check if an item exists.
*
* @param {array} item
* The array of ids leading up to the item.
*/
exists(item) {
let bookmark = this.root;
for (const id of item) {
if (bookmark.items.has(id)) {
bookmark = bookmark.items.get(id);
} else {
return false;
}
}
return true;
},
/**
* 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(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() {
this.root.remove();
this.setupRoot();
this.attachments.clear();
if (this.emptyText) {
this.setPlaceholderText(this.emptyText);
}
},
/**
* Expands the tree completely
*/
expandAll() {
this.root.expandAll();
},
/**
* Collapses the tree completely
*/
collapseAll() {
this.root.collapseAll();
},
/**
* Click handler for the tree. Used to select, open and close the tree nodes.
*/
onClick(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) {
const ids = target.parentNode.getAttribute("data-id");
this.selectedItem = JSON.parse(ids);
}
},
/**
* Keydown handler for this tree. Used to select next and previous visible
* items, as well as collapsing and expanding any item.
*/
onKeydown(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() {
const { top, bottom } = this._selectedLabel.getBoundingClientRect();
const 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(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.
const 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];
const 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;
}
const 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
const 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(items = []) {
const id = items.shift();
if (id && this.items.has(id)) {
const 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(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])) {
const 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() {
if (this.label) {
this.label.removeAttribute("expanded");
}
for (const child of this.items.values()) {
child.collapseAll();
}
},
/**
* Expands this item and all of its sub tree items
*/
expandAll() {
if (this.label) {
this.label.setAttribute("expanded", "true");
}
for (const child of this.items.values()) {
child.expandAll();
}
},
destroy() {
this.children.remove();
this.node.remove();
this.label = null;
this.items = null;
this.children = null;
},
};