TreeWidget.js (18400B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const HTML_NS = "http://www.w3.org/1999/xhtml"; 7 8 const EventEmitter = require("resource://devtools/shared/event-emitter.js"); 9 const { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 10 11 /** 12 * A tree widget with keyboard navigation and collapsable structure. 13 */ 14 class TreeWidget extends EventEmitter { 15 /** 16 * @param {Node} node 17 * The container element for the tree widget. 18 * @param {object} options 19 * @param {string} [options.emptyText] text to display when no entries in the table. 20 * @param {string} options.defaultType The default type of the tree items. For ex. 21 * 'js' 22 * @param {boolean} [options.sorted] Defaults to true. If true, tree items are kept in 23 * lexical order. If false, items will be kept in insertion order. 24 * @param {string} [options.contextMenuId] ID of context menu to be displayed on 25 * tree items. 26 */ 27 constructor(node, options = {}) { 28 super(); 29 30 this.document = node.ownerDocument; 31 this.window = this.document.defaultView; 32 this._parent = node; 33 34 this.emptyText = options.emptyText || ""; 35 this.defaultType = options.defaultType; 36 this.sorted = options.sorted !== false; 37 this.contextMenuId = options.contextMenuId; 38 39 this.setupRoot(); 40 41 this.placeholder = this.document.createElementNS(HTML_NS, "label"); 42 this.placeholder.className = "tree-widget-empty-text"; 43 this._parent.appendChild(this.placeholder); 44 45 if (this.emptyText) { 46 this.setPlaceholderText(this.emptyText); 47 } 48 // A map to hold all the passed attachment to each leaf in the tree. 49 this.attachments = new Map(); 50 } 51 52 _selectedLabel = null; 53 _selectedItem = null; 54 /** 55 * Select any node in the tree. 56 * 57 * @param {Array} ids 58 * An array of ids leading upto the selected item 59 */ 60 set selectedItem(ids) { 61 if (this._selectedLabel) { 62 this._selectedLabel.classList.remove("theme-selected"); 63 } 64 const currentSelected = this._selectedLabel; 65 if (ids == -1) { 66 this._selectedLabel = this._selectedItem = null; 67 return; 68 } 69 if (!Array.isArray(ids)) { 70 return; 71 } 72 this._selectedLabel = this.root.setSelectedItem(ids); 73 if (!this._selectedLabel) { 74 this._selectedItem = null; 75 } else { 76 if (currentSelected != this._selectedLabel) { 77 this.ensureSelectedVisible(); 78 } 79 this._selectedItem = ids; 80 this.emit( 81 "select", 82 this._selectedItem, 83 this.attachments.get(JSON.stringify(ids)) 84 ); 85 } 86 } 87 88 /** 89 * Gets the selected item in the tree. 90 * 91 * @return {Array} 92 * An array of ids leading upto the selected item 93 */ 94 get selectedItem() { 95 return this._selectedItem; 96 } 97 98 /** 99 * Returns if the passed array corresponds to the selected item in the tree. 100 * 101 * @return {Array} 102 * An array of ids leading upto the requested item 103 */ 104 isSelected(item) { 105 if (!this._selectedItem || this._selectedItem.length != item.length) { 106 return false; 107 } 108 109 for (let i = 0; i < this._selectedItem.length; i++) { 110 if (this._selectedItem[i] != item[i]) { 111 return false; 112 } 113 } 114 115 return true; 116 } 117 118 destroy() { 119 this.root.remove(); 120 this.root = null; 121 } 122 123 /** 124 * Sets up the root container of the TreeWidget. 125 */ 126 setupRoot() { 127 this.root = new TreeItem(this.document); 128 if (this.contextMenuId) { 129 this.root.children.addEventListener("contextmenu", event => { 130 // Call stopPropagation() and preventDefault() here so that avoid to show default 131 // context menu in about:devtools-toolbox. See Bug 1515265. 132 event.stopPropagation(); 133 event.preventDefault(); 134 const menu = this.document.getElementById(this.contextMenuId); 135 menu.openPopupAtScreen(event.screenX, event.screenY, true); 136 }); 137 } 138 139 this._parent.appendChild(this.root.children); 140 141 this.root.children.addEventListener("mousedown", e => this.onClick(e)); 142 this.root.children.addEventListener("keydown", e => this.onKeydown(e)); 143 } 144 145 /** 146 * Sets the text to be shown when no node is present in the tree. 147 * The placeholder will be hidden if text is empty. 148 */ 149 setPlaceholderText(text) { 150 this.placeholder.textContent = text; 151 if (text) { 152 this.placeholder.removeAttribute("hidden"); 153 } else { 154 this.placeholder.setAttribute("hidden", "true"); 155 } 156 } 157 158 /** 159 * Select any node in the tree. 160 * 161 * @param {Array} id 162 * An array of ids leading upto the selected item 163 */ 164 selectItem(id) { 165 this.selectedItem = id; 166 } 167 168 /** 169 * Selects the next visible item in the tree. 170 */ 171 selectNextItem() { 172 const next = this.getNextVisibleItem(); 173 if (next) { 174 this.selectedItem = next; 175 } 176 } 177 178 /** 179 * Selects the previos visible item in the tree 180 */ 181 selectPreviousItem() { 182 const prev = this.getPreviousVisibleItem(); 183 if (prev) { 184 this.selectedItem = prev; 185 } 186 } 187 188 /** 189 * Returns the next visible item in the tree 190 */ 191 getNextVisibleItem() { 192 let node = this._selectedLabel; 193 if (node.hasAttribute("expanded") && node.nextSibling.firstChild) { 194 return JSON.parse(node.nextSibling.firstChild.getAttribute("data-id")); 195 } 196 node = node.parentNode; 197 if (node.nextSibling) { 198 return JSON.parse(node.nextSibling.getAttribute("data-id")); 199 } 200 node = node.parentNode; 201 while (node.parentNode && node != this.root.children) { 202 if (node.parentNode?.nextSibling) { 203 return JSON.parse(node.parentNode.nextSibling.getAttribute("data-id")); 204 } 205 node = node.parentNode; 206 } 207 return null; 208 } 209 210 /** 211 * Returns the previous visible item in the tree 212 */ 213 getPreviousVisibleItem() { 214 let node = this._selectedLabel.parentNode; 215 if (node.previousSibling) { 216 node = node.previousSibling.firstChild; 217 while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) { 218 if (!node.nextSibling.lastChild) { 219 break; 220 } 221 node = node.nextSibling.lastChild.firstChild; 222 } 223 return JSON.parse(node.parentNode.getAttribute("data-id")); 224 } 225 node = node.parentNode; 226 if (node.parentNode && node != this.root.children) { 227 node = node.parentNode; 228 while (node.hasAttribute("expanded") && !node.hasAttribute("empty")) { 229 if (!node.nextSibling.firstChild) { 230 break; 231 } 232 node = node.nextSibling.firstChild.firstChild; 233 } 234 return JSON.parse(node.getAttribute("data-id")); 235 } 236 return null; 237 } 238 239 clearSelection() { 240 this.selectedItem = -1; 241 } 242 243 /** 244 * Adds an item in the tree. The item can be added as a child to any node in 245 * the tree. The method will also create any subnode not present in the 246 * process. 247 * 248 * @param {[string|object]} items 249 * An array of either string or objects where each increasing index 250 * represents an item corresponding to an equivalent depth in the tree. 251 * Each array element can be either just a string with the value as the 252 * id of of that item as well as the display value, or it can be an 253 * object with the following propeties: 254 * - id {string} The id of the item 255 * - label {string} The display value of the item 256 * - node {DOMNode} The dom node if you want to insert some custom 257 * element as the item. The label property is not used in this 258 * case 259 * - attachment {object} Any object to be associated with this item. 260 * - type {string} The type of this particular item. If this is null, 261 * then defaultType will be used. 262 * For example, if items = ["foo", "bar", { id: "id1", label: "baz" }] 263 * and the tree is empty, then the following hierarchy will be created 264 * in the tree: 265 * foo 266 * └ bar 267 * └ baz 268 * Passing the string id instead of the complete object helps when you 269 * are simply adding children to an already existing node and you know 270 * its id. 271 */ 272 add(items) { 273 this.root.add(items, this.defaultType, this.sorted); 274 for (let i = 0; i < items.length; i++) { 275 if (items[i].attachment) { 276 this.attachments.set( 277 JSON.stringify(items.slice(0, i + 1).map(item => item.id || item)), 278 items[i].attachment 279 ); 280 } 281 } 282 // Empty the empty-tree-text 283 this.setPlaceholderText(""); 284 } 285 286 /** 287 * Check if an item exists. 288 * 289 * @param {Array} item 290 * The array of ids leading up to the item. 291 */ 292 exists(item) { 293 let bookmark = this.root; 294 295 for (const id of item) { 296 if (bookmark.items.has(id)) { 297 bookmark = bookmark.items.get(id); 298 } else { 299 return false; 300 } 301 } 302 return true; 303 } 304 305 /** 306 * Removes the specified item and all of its child items from the tree. 307 * 308 * @param {Array} item 309 * The array of ids leading up to the item. 310 */ 311 remove(item) { 312 this.root.remove(item); 313 this.attachments.delete(JSON.stringify(item)); 314 // Display the empty tree text 315 if (this.root.items.size == 0 && this.emptyText) { 316 this.setPlaceholderText(this.emptyText); 317 } 318 } 319 320 /** 321 * Removes all of the child nodes from this tree. 322 */ 323 clear() { 324 this.root.remove(); 325 this.setupRoot(); 326 this.attachments.clear(); 327 if (this.emptyText) { 328 this.setPlaceholderText(this.emptyText); 329 } 330 } 331 332 /** 333 * Expands the tree completely 334 */ 335 expandAll() { 336 this.root.expandAll(); 337 } 338 339 /** 340 * Collapses the tree completely 341 */ 342 collapseAll() { 343 this.root.collapseAll(); 344 } 345 346 /** 347 * Click handler for the tree. Used to select, open and close the tree nodes. 348 */ 349 onClick(event) { 350 let target = event.originalTarget; 351 while (target && !target.classList.contains("tree-widget-item")) { 352 if (target == this.root.children) { 353 return; 354 } 355 target = target.parentNode; 356 } 357 if (!target) { 358 return; 359 } 360 361 if (target.hasAttribute("expanded")) { 362 target.removeAttribute("expanded"); 363 } else { 364 target.setAttribute("expanded", "true"); 365 } 366 367 if (this._selectedLabel != target) { 368 const ids = target.parentNode.getAttribute("data-id"); 369 this.selectedItem = JSON.parse(ids); 370 } 371 } 372 373 /** 374 * Keydown handler for this tree. Used to select next and previous visible 375 * items, as well as collapsing and expanding any item. 376 */ 377 onKeydown(event) { 378 switch (event.keyCode) { 379 case KeyCodes.DOM_VK_UP: 380 this.selectPreviousItem(); 381 break; 382 383 case KeyCodes.DOM_VK_DOWN: 384 this.selectNextItem(); 385 break; 386 387 case KeyCodes.DOM_VK_RIGHT: 388 if (this._selectedLabel.hasAttribute("expanded")) { 389 this.selectNextItem(); 390 } else { 391 this._selectedLabel.setAttribute("expanded", "true"); 392 } 393 break; 394 395 case KeyCodes.DOM_VK_LEFT: 396 if ( 397 this._selectedLabel.hasAttribute("expanded") && 398 !this._selectedLabel.hasAttribute("empty") 399 ) { 400 this._selectedLabel.removeAttribute("expanded"); 401 } else { 402 this.selectPreviousItem(); 403 } 404 break; 405 406 default: 407 return; 408 } 409 event.preventDefault(); 410 } 411 412 /** 413 * Scrolls the viewport of the tree so that the selected item is always 414 * visible. 415 */ 416 ensureSelectedVisible() { 417 const { top, bottom } = this._selectedLabel.getBoundingClientRect(); 418 const height = this.root.children.parentNode.clientHeight; 419 if (top < 0) { 420 this._selectedLabel.scrollIntoView(); 421 } else if (bottom > height) { 422 this._selectedLabel.scrollIntoView(false); 423 } 424 } 425 } 426 427 module.exports.TreeWidget = TreeWidget; 428 429 /** 430 * Any item in the tree. This can be an empty leaf node also. 431 */ 432 class TreeItem { 433 /** 434 * @param {HTMLDocument} document 435 * The document element used for creating new nodes. 436 * @param {TreeItem} parent 437 * The parent item for this item. 438 * @param {string|DOMElement} label 439 * Either the dom node to be used as the item, or the string to be 440 * displayed for this node in the tree 441 * @param {string} type 442 * The type of the current node. For ex. "js" 443 */ 444 constructor(document, parent, label, type) { 445 this.document = document; 446 this.node = this.document.createElementNS(HTML_NS, "li"); 447 this.node.setAttribute("tabindex", "0"); 448 this.isRoot = !parent; 449 this.parent = parent; 450 if (this.parent) { 451 this.level = this.parent.level + 1; 452 } 453 if (label) { 454 this.label = this.document.createElementNS(HTML_NS, "div"); 455 this.label.setAttribute("empty", "true"); 456 this.label.setAttribute("level", this.level); 457 this.label.className = "tree-widget-item"; 458 if (type) { 459 this.label.setAttribute("type", type); 460 } 461 if (typeof label == "string") { 462 this.label.textContent = label; 463 } else { 464 this.label.appendChild(label); 465 } 466 this.node.appendChild(this.label); 467 } 468 this.children = this.document.createElementNS(HTML_NS, "ul"); 469 if (this.isRoot) { 470 this.children.className = "tree-widget-container"; 471 } else { 472 this.children.className = "tree-widget-children"; 473 } 474 this.node.appendChild(this.children); 475 this.items = new Map(); 476 } 477 478 items = null; 479 480 isSelected = false; 481 482 expanded = false; 483 484 isRoot = false; 485 486 parent = null; 487 488 children = null; 489 490 level = 0; 491 492 /** 493 * Adds the item to the sub tree contained by this node. The item to be 494 * inserted can be a direct child of this node, or further down the tree. 495 * 496 * @param {Array} items 497 * Same as TreeWidget.add method's argument 498 * @param {string} defaultType 499 * The default type of the item to be used when items[i].type is null 500 * @param {boolean} sorted 501 * true if the tree items are inserted in a lexically sorted manner. 502 * Otherwise, false if the item are to be appended to their parent. 503 */ 504 add(items, defaultType, sorted) { 505 if (items.length == this.level) { 506 // This is the exit condition of recursive TreeItem.add calls 507 return; 508 } 509 // Get the id and label corresponding to this level inside the tree. 510 const id = items[this.level].id || items[this.level]; 511 if (this.items.has(id)) { 512 // An item with same id already exists, thus calling the add method of 513 // that child to add the passed node at correct position. 514 this.items.get(id).add(items, defaultType, sorted); 515 return; 516 } 517 // No item with the id `id` exists, so we create one and call the add 518 // method of that item. 519 // The display string of the item can be the label, the id, or the item 520 // itself if its a plain string. 521 let label = 522 items[this.level].label || items[this.level].id || items[this.level]; 523 const node = items[this.level].node; 524 if (node) { 525 // The item is supposed to be a DOMNode, so we fetch the textContent in 526 // order to find the correct sorted location of this new item. 527 label = node.textContent; 528 } 529 const treeItem = new TreeItem( 530 this.document, 531 this, 532 node || label, 533 items[this.level].type || defaultType 534 ); 535 536 treeItem.add(items, defaultType, sorted); 537 treeItem.node.setAttribute( 538 "data-id", 539 JSON.stringify( 540 items.slice(0, this.level + 1).map(item => item.id || item) 541 ) 542 ); 543 544 if (sorted) { 545 // Inserting this newly created item at correct position 546 const nextSibling = [...this.items.values()].find(child => { 547 return child.label.textContent >= label; 548 }); 549 550 if (nextSibling) { 551 this.children.insertBefore(treeItem.node, nextSibling.node); 552 } else { 553 this.children.appendChild(treeItem.node); 554 } 555 } else { 556 this.children.appendChild(treeItem.node); 557 } 558 559 if (this.label) { 560 this.label.removeAttribute("empty"); 561 } 562 this.items.set(id, treeItem); 563 } 564 565 /** 566 * If this item is to be removed, then removes this item and thus all of its 567 * subtree. Otherwise, call the remove method of appropriate child. This 568 * recursive method goes on till we have reached the end of the branch or the 569 * current item is to be removed. 570 * 571 * @param {Array} items 572 * Ids of items leading up to the item to be removed. 573 */ 574 remove(items = []) { 575 const id = items.shift(); 576 if (id && this.items.has(id)) { 577 const deleted = this.items.get(id); 578 if (!items.length) { 579 this.items.delete(id); 580 } 581 if (this.items.size == 0) { 582 this.label.setAttribute("empty", "true"); 583 } 584 deleted.remove(items); 585 } else if (!id) { 586 this.destroy(); 587 } 588 } 589 590 /** 591 * If this item is to be selected, then selected and expands the item. 592 * Otherwise, if a child item is to be selected, just expands this item. 593 * 594 * @param {Array} items 595 * Ids of items leading up to the item to be selected. 596 */ 597 setSelectedItem(items) { 598 if (!items[this.level]) { 599 this.label.classList.add("theme-selected"); 600 this.label.setAttribute("expanded", "true"); 601 return this.label; 602 } 603 if (this.items.has(items[this.level])) { 604 const label = this.items.get(items[this.level]).setSelectedItem(items); 605 if (label && this.label) { 606 this.label.setAttribute("expanded", true); 607 } 608 return label; 609 } 610 return null; 611 } 612 613 /** 614 * Collapses this item and all of its sub tree items 615 */ 616 collapseAll() { 617 if (this.label) { 618 this.label.removeAttribute("expanded"); 619 } 620 for (const child of this.items.values()) { 621 child.collapseAll(); 622 } 623 } 624 625 /** 626 * Expands this item and all of its sub tree items 627 */ 628 expandAll() { 629 if (this.label) { 630 this.label.setAttribute("expanded", "true"); 631 } 632 for (const child of this.items.values()) { 633 child.expandAll(); 634 } 635 } 636 637 destroy() { 638 this.children.remove(); 639 this.node.remove(); 640 this.label = null; 641 this.items = null; 642 this.children = null; 643 } 644 }