643 lines
18 KiB
JavaScript
643 lines
18 KiB
JavaScript
/* 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("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
|
|
// context menu in about:devtools-toolbox. See Bug 1515265.
|
|
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;
|
|
},
|
|
};
|