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 6const { require, loader } = ChromeUtils.import( 7 "resource://devtools/shared/loader/Loader.jsm" 8); 9const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers"); 10const { KeyCodes } = require("devtools/client/shared/keycodes"); 11 12loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter"); 13 14const EXPORTED_SYMBOLS = ["AbstractTreeItem"]; 15 16/** 17 * A very generic and low-level tree view implementation. It is not intended 18 * to be used alone, but as a base class that you can extend to build your 19 * own custom implementation. 20 * 21 * Language: 22 * - An "item" is an instance of an AbstractTreeItem. 23 * - An "element" or "node" is a Node. 24 * 25 * The following events are emitted by this tree, always from the root item, 26 * with the first argument pointing to the affected child item: 27 * - "expand": when an item is expanded in the tree 28 * - "collapse": when an item is collapsed in the tree 29 * - "focus": when an item is selected in the tree 30 * 31 * For example, you can extend this abstract class like this: 32 * 33 * function MyCustomTreeItem(dataSrc, properties) { 34 * AbstractTreeItem.call(this, properties); 35 * this.itemDataSrc = dataSrc; 36 * } 37 * 38 * MyCustomTreeItem.prototype = extend(AbstractTreeItem.prototype, { 39 * _displaySelf: function(document, arrowNode) { 40 * let node = document.createXULElement("hbox"); 41 * ... 42 * // Append the provided arrow node wherever you want. 43 * node.appendChild(arrowNode); 44 * ... 45 * // Use `this.itemDataSrc` to customize the tree item and 46 * // `this.level` to calculate the indentation. 47 * node.style.marginInlineStart = (this.level * 10) + "px"; 48 * node.appendChild(document.createTextNode(this.itemDataSrc.label)); 49 * ... 50 * return node; 51 * }, 52 * _populateSelf: function(children) { 53 * ... 54 * // Use `this.itemDataSrc` to get the data source for the child items. 55 * let someChildDataSrc = this.itemDataSrc.children[0]; 56 * ... 57 * children.push(new MyCustomTreeItem(someChildDataSrc, { 58 * parent: this, 59 * level: this.level + 1 60 * })); 61 * ... 62 * } 63 * }); 64 * 65 * And then you could use it like this: 66 * 67 * let dataSrc = { 68 * label: "root", 69 * children: [{ 70 * label: "foo", 71 * children: [] 72 * }, { 73 * label: "bar", 74 * children: [{ 75 * label: "baz", 76 * children: [] 77 * }] 78 * }] 79 * }; 80 * let root = new MyCustomTreeItem(dataSrc, { parent: null }); 81 * root.attachTo(Node); 82 * root.expand(); 83 * 84 * The following tree view will be generated (after expanding all nodes): 85 * ▼ root 86 * ▶ foo 87 * ▼ bar 88 * ▶ baz 89 * 90 * The way the data source is implemented is completely up to you. There's 91 * no assumptions made and you can use it however you like inside the 92 * `_displaySelf` and `populateSelf` methods. If you need to add children to a 93 * node at a later date, you just need to modify the data source: 94 * 95 * dataSrc[...path-to-foo...].children.push({ 96 * label: "lazily-added-node" 97 * children: [] 98 * }); 99 * 100 * The existing tree view will be modified like so (after expanding `foo`): 101 * ▼ root 102 * ▼ foo 103 * ▶ lazily-added-node 104 * ▼ bar 105 * ▶ baz 106 * 107 * Everything else is taken care of automagically! 108 * 109 * @param AbstractTreeItem parent 110 * The parent tree item. Should be null for root items. 111 * @param number level 112 * The indentation level in the tree. The root item is at level 0. 113 */ 114function AbstractTreeItem({ parent, level }) { 115 this._rootItem = parent ? parent._rootItem : this; 116 this._parentItem = parent; 117 this._level = level || 0; 118 this._childTreeItems = []; 119 120 // Events are always propagated through the root item. Decorating every 121 // tree item as an event emitter is a very costly operation. 122 if (this == this._rootItem) { 123 EventEmitter.decorate(this); 124 } 125} 126 127AbstractTreeItem.prototype = { 128 _containerNode: null, 129 _targetNode: null, 130 _arrowNode: null, 131 _constructed: false, 132 _populated: false, 133 _expanded: false, 134 135 /** 136 * Optionally, trees may be allowed to automatically expand a few levels deep 137 * to avoid initially displaying a completely collapsed tree. 138 */ 139 autoExpandDepth: 0, 140 141 /** 142 * Creates the view for this tree item. Implement this method in the 143 * inheriting classes to create the child node displayed in the tree. 144 * Use `this.level` and the provided `arrowNode` as you see fit. 145 * 146 * @param Node document 147 * @param Node arrowNode 148 * @return Node 149 */ 150 _displaySelf: function(document, arrowNode) { 151 throw new Error( 152 "The `_displaySelf` method needs to be implemented by inheriting classes." 153 ); 154 }, 155 156 /** 157 * Populates this tree item with child items, whenever it's expanded. 158 * Implement this method in the inheriting classes to fill the provided 159 * `children` array with AbstractTreeItem instances, which will then be 160 * magically handled by this tree item. 161 * 162 * @param array:AbstractTreeItem children 163 */ 164 _populateSelf: function(children) { 165 throw new Error( 166 "The `_populateSelf` method needs to be implemented by inheriting classes." 167 ); 168 }, 169 170 /** 171 * Gets the this tree's owner document. 172 * @return Document 173 */ 174 get document() { 175 return this._containerNode.ownerDocument; 176 }, 177 178 /** 179 * Gets the root item of this tree. 180 * @return AbstractTreeItem 181 */ 182 get root() { 183 return this._rootItem; 184 }, 185 186 /** 187 * Gets the parent of this tree item. 188 * @return AbstractTreeItem 189 */ 190 get parent() { 191 return this._parentItem; 192 }, 193 194 /** 195 * Gets the indentation level of this tree item. 196 */ 197 get level() { 198 return this._level; 199 }, 200 201 /** 202 * Gets the element displaying this tree item. 203 */ 204 get target() { 205 return this._targetNode; 206 }, 207 208 /** 209 * Gets the element containing all tree items. 210 * @return Node 211 */ 212 get container() { 213 return this._containerNode; 214 }, 215 216 /** 217 * Returns whether or not this item is populated in the tree. 218 * Collapsed items can still be populated. 219 * @return boolean 220 */ 221 get populated() { 222 return this._populated; 223 }, 224 225 /** 226 * Returns whether or not this item is expanded in the tree. 227 * Expanded items with no children aren't consudered `populated`. 228 * @return boolean 229 */ 230 get expanded() { 231 return this._expanded; 232 }, 233 234 /** 235 * Gets the bounds for this tree's container without flushing. 236 * @return object 237 */ 238 get bounds() { 239 const win = this.document.defaultView; 240 const utils = win.windowUtils; 241 return utils.getBoundsWithoutFlushing(this._containerNode); 242 }, 243 244 /** 245 * Creates and appends this tree item to the specified parent element. 246 * 247 * @param Node containerNode 248 * The parent element for this tree item (and every other tree item). 249 * @param Node fragmentNode [optional] 250 * An optional document fragment temporarily holding this tree item in 251 * the current batch. Defaults to the `containerNode`. 252 * @param Node beforeNode [optional] 253 * An optional child element which should succeed this tree item. 254 */ 255 attachTo: function( 256 containerNode, 257 fragmentNode = containerNode, 258 beforeNode = null 259 ) { 260 this._containerNode = containerNode; 261 this._constructTargetNode(); 262 263 if (beforeNode) { 264 fragmentNode.insertBefore(this._targetNode, beforeNode); 265 } else { 266 fragmentNode.appendChild(this._targetNode); 267 } 268 269 if (this._level < this.autoExpandDepth) { 270 this.expand(); 271 } 272 }, 273 274 /** 275 * Permanently removes this tree item (and all subsequent children) from the 276 * parent container. 277 */ 278 remove: function() { 279 this._targetNode.remove(); 280 this._hideChildren(); 281 this._childTreeItems.length = 0; 282 }, 283 284 /** 285 * Focuses this item in the tree. 286 */ 287 focus: function() { 288 this._targetNode.focus(); 289 }, 290 291 /** 292 * Expands this item in the tree. 293 */ 294 expand: function() { 295 if (this._expanded) { 296 return; 297 } 298 this._expanded = true; 299 this._arrowNode.setAttribute("open", ""); 300 this._targetNode.setAttribute("expanded", ""); 301 this._toggleChildren(true); 302 this._rootItem.emit("expand", this); 303 }, 304 305 /** 306 * Collapses this item in the tree. 307 */ 308 collapse: function() { 309 if (!this._expanded) { 310 return; 311 } 312 this._expanded = false; 313 this._arrowNode.removeAttribute("open"); 314 this._targetNode.removeAttribute("expanded", ""); 315 this._toggleChildren(false); 316 this._rootItem.emit("collapse", this); 317 }, 318 319 /** 320 * Returns the child item at the specified index. 321 * 322 * @param number index 323 * @return AbstractTreeItem 324 */ 325 getChild: function(index = 0) { 326 return this._childTreeItems[index]; 327 }, 328 329 /** 330 * Calls the provided function on all the descendants of this item. 331 * If this item was never expanded, then no descendents exist yet. 332 * @param function cb 333 */ 334 traverse: function(cb) { 335 for (const child of this._childTreeItems) { 336 cb(child); 337 child.bfs(); 338 } 339 }, 340 341 /** 342 * Calls the provided function on all descendants of this item until 343 * a truthy value is returned by the predicate. 344 * @param function predicate 345 * @return AbstractTreeItem 346 */ 347 find: function(predicate) { 348 for (const child of this._childTreeItems) { 349 if (predicate(child) || child.find(predicate)) { 350 return child; 351 } 352 } 353 return null; 354 }, 355 356 /** 357 * Shows or hides all the children of this item in the tree. If neessary, 358 * populates this item with children. 359 * 360 * @param boolean visible 361 * True if the children should be visible, false otherwise. 362 */ 363 _toggleChildren: function(visible) { 364 if (visible) { 365 if (!this._populated) { 366 this._populateSelf(this._childTreeItems); 367 this._populated = this._childTreeItems.length > 0; 368 } 369 this._showChildren(); 370 } else { 371 this._hideChildren(); 372 } 373 }, 374 375 /** 376 * Shows all children of this item in the tree. 377 */ 378 _showChildren: function() { 379 // If this is the root item and we're not expanding any child nodes, 380 // it is safe to append everything at once. 381 if (this == this._rootItem && this.autoExpandDepth == 0) { 382 this._appendChildrenBatch(); 383 } else { 384 // Otherwise, append the child items and their descendants successively; 385 // if not, the tree will become garbled and nodes will intertwine, 386 // since all the tree items are sharing a single container node. 387 this._appendChildrenSuccessive(); 388 } 389 }, 390 391 /** 392 * Hides all children of this item in the tree. 393 */ 394 _hideChildren: function() { 395 for (const item of this._childTreeItems) { 396 item._targetNode.remove(); 397 item._hideChildren(); 398 } 399 }, 400 401 /** 402 * Appends all children in a single batch. 403 * This only works properly for root nodes when no child nodes will expand. 404 */ 405 _appendChildrenBatch: function() { 406 if (this._fragment === undefined) { 407 this._fragment = this.document.createDocumentFragment(); 408 } 409 410 const childTreeItems = this._childTreeItems; 411 412 for (let i = 0, len = childTreeItems.length; i < len; i++) { 413 childTreeItems[i].attachTo(this._containerNode, this._fragment); 414 } 415 416 this._containerNode.appendChild(this._fragment); 417 }, 418 419 /** 420 * Appends all children successively. 421 */ 422 _appendChildrenSuccessive: function() { 423 const childTreeItems = this._childTreeItems; 424 const expandedChildTreeItems = childTreeItems.filter(e => e._expanded); 425 const nextNode = this._getSiblingAtDelta(1); 426 427 for (let i = 0, len = childTreeItems.length; i < len; i++) { 428 childTreeItems[i].attachTo(this._containerNode, undefined, nextNode); 429 } 430 for (let i = 0, len = expandedChildTreeItems.length; i < len; i++) { 431 expandedChildTreeItems[i]._showChildren(); 432 } 433 }, 434 435 /** 436 * Constructs and stores the target node displaying this tree item. 437 */ 438 _constructTargetNode: function() { 439 if (this._constructed) { 440 return; 441 } 442 this._onArrowClick = this._onArrowClick.bind(this); 443 this._onClick = this._onClick.bind(this); 444 this._onDoubleClick = this._onDoubleClick.bind(this); 445 this._onKeyDown = this._onKeyDown.bind(this); 446 this._onFocus = this._onFocus.bind(this); 447 this._onBlur = this._onBlur.bind(this); 448 449 const document = this.document; 450 451 const arrowNode = (this._arrowNode = document.createXULElement("hbox")); 452 arrowNode.className = "arrow theme-twisty"; 453 arrowNode.addEventListener("mousedown", this._onArrowClick); 454 455 const targetNode = (this._targetNode = this._displaySelf( 456 document, 457 arrowNode 458 )); 459 targetNode.style.MozUserFocus = "normal"; 460 461 targetNode.addEventListener("mousedown", this._onClick); 462 targetNode.addEventListener("dblclick", this._onDoubleClick); 463 targetNode.addEventListener("keydown", this._onKeyDown); 464 targetNode.addEventListener("focus", this._onFocus); 465 targetNode.addEventListener("blur", this._onBlur); 466 467 this._constructed = true; 468 }, 469 470 /** 471 * Gets the element displaying an item in the tree at the specified offset 472 * relative to this item. 473 * 474 * @param number delta 475 * The offset from this item to the target item. 476 * @return Node 477 * The element displaying the target item at the specified offset. 478 */ 479 _getSiblingAtDelta: function(delta) { 480 const childNodes = this._containerNode.childNodes; 481 const indexOfSelf = Array.prototype.indexOf.call( 482 childNodes, 483 this._targetNode 484 ); 485 if (indexOfSelf + delta >= 0) { 486 return childNodes[indexOfSelf + delta]; 487 } 488 return undefined; 489 }, 490 491 _getNodesPerPageSize: function() { 492 const childNodes = this._containerNode.childNodes; 493 const nodeHeight = this._getHeight(childNodes[childNodes.length - 1]); 494 const containerHeight = this.bounds.height; 495 return Math.ceil(containerHeight / nodeHeight); 496 }, 497 498 _getHeight: function(elem) { 499 const win = this.document.defaultView; 500 const utils = win.windowUtils; 501 return utils.getBoundsWithoutFlushing(elem).height; 502 }, 503 504 /** 505 * Focuses the first item in this tree. 506 */ 507 _focusFirstNode: function() { 508 const childNodes = this._containerNode.childNodes; 509 // The root node of the tree may be hidden in practice, so uses for-loop 510 // here to find the next visible node. 511 for (let i = 0; i < childNodes.length; i++) { 512 // The height will be 0 if an element is invisible. 513 if (this._getHeight(childNodes[i])) { 514 childNodes[i].focus(); 515 return; 516 } 517 } 518 }, 519 520 /** 521 * Focuses the last item in this tree. 522 */ 523 _focusLastNode: function() { 524 const childNodes = this._containerNode.childNodes; 525 childNodes[childNodes.length - 1].focus(); 526 }, 527 528 /** 529 * Focuses the next item in this tree. 530 */ 531 _focusNextNode: function() { 532 const nextElement = this._getSiblingAtDelta(1); 533 if (nextElement) { 534 nextElement.focus(); 535 } // Node 536 }, 537 538 /** 539 * Focuses the previous item in this tree. 540 */ 541 _focusPrevNode: function() { 542 const prevElement = this._getSiblingAtDelta(-1); 543 if (prevElement) { 544 prevElement.focus(); 545 } // Node 546 }, 547 548 /** 549 * Focuses the parent item in this tree. 550 * 551 * The parent item is not always the previous item, because any tree item 552 * may have multiple children. 553 */ 554 _focusParentNode: function() { 555 const parentItem = this._parentItem; 556 if (parentItem) { 557 parentItem.focus(); 558 } // AbstractTreeItem 559 }, 560 561 /** 562 * Handler for the "click" event on the arrow node of this tree item. 563 */ 564 _onArrowClick: function(e) { 565 if (!this._expanded) { 566 this.expand(); 567 } else { 568 this.collapse(); 569 } 570 }, 571 572 /** 573 * Handler for the "click" event on the element displaying this tree item. 574 */ 575 _onClick: function(e) { 576 e.stopPropagation(); 577 this.focus(); 578 }, 579 580 /** 581 * Handler for the "dblclick" event on the element displaying this tree item. 582 */ 583 _onDoubleClick: function(e) { 584 // Ignore dblclick on the arrow as it has already recived and handled two 585 // click events. 586 if (!e.target.classList.contains("arrow")) { 587 this._onArrowClick(e); 588 } 589 this.focus(); 590 }, 591 592 /** 593 * Handler for the "keydown" event on the element displaying this tree item. 594 */ 595 _onKeyDown: function(e) { 596 // Prevent scrolling when pressing navigation keys. 597 ViewHelpers.preventScrolling(e); 598 599 switch (e.keyCode) { 600 case KeyCodes.DOM_VK_UP: 601 this._focusPrevNode(); 602 return; 603 604 case KeyCodes.DOM_VK_DOWN: 605 this._focusNextNode(); 606 return; 607 608 case KeyCodes.DOM_VK_LEFT: 609 if (this._expanded && this._populated) { 610 this.collapse(); 611 } else { 612 this._focusParentNode(); 613 } 614 return; 615 616 case KeyCodes.DOM_VK_RIGHT: 617 if (!this._expanded) { 618 this.expand(); 619 } else { 620 this._focusNextNode(); 621 } 622 return; 623 624 case KeyCodes.DOM_VK_PAGE_UP: 625 const pageUpElement = this._getSiblingAtDelta( 626 -this._getNodesPerPageSize() 627 ); 628 // There's a chance that the root node is hidden. In this case, its 629 // height will be 0. 630 if (pageUpElement && this._getHeight(pageUpElement)) { 631 pageUpElement.focus(); 632 } else { 633 this._focusFirstNode(); 634 } 635 return; 636 637 case KeyCodes.DOM_VK_PAGE_DOWN: 638 const pageDownElement = this._getSiblingAtDelta( 639 this._getNodesPerPageSize() 640 ); 641 if (pageDownElement) { 642 pageDownElement.focus(); 643 } else { 644 this._focusLastNode(); 645 } 646 return; 647 648 case KeyCodes.DOM_VK_HOME: 649 this._focusFirstNode(); 650 return; 651 652 case KeyCodes.DOM_VK_END: 653 this._focusLastNode(); 654 } 655 }, 656 657 /** 658 * Handler for the "focus" event on the element displaying this tree item. 659 */ 660 _onFocus: function(e) { 661 this._rootItem.emit("focus", this); 662 }, 663 664 /** 665 * Handler for the "blur" event on the element displaying this tree item. 666 */ 667 _onBlur: function(e) { 668 this._rootItem.emit("blur", this); 669 }, 670}; 671