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