mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-02 07:05:24 +00:00
efe328f1b2
In a following patch, all DevTools moz.build files will use DevToolsModules to install JS modules at a path that corresponds directly to their source tree location. Here we rewrite all require and import calls to match the new location that these files are installed to. --HG-- extra : commitid : F2ItGm8ptRz extra : rebase_source : b082fe4bf77e22e297e303fc601165ceff1c4cbc
598 lines
17 KiB
JavaScript
598 lines
17 KiB
JavaScript
/* -*- 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/shared/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;
|
|
}
|
|
};
|