Bug 1874684 - Part 4: Prefer const references instead of copying Instant values....
[gecko.git] / devtools / client / inspector / breadcrumbs.js
blob47700f7d401a8c69ae0c4e1e96278295f03bd28d
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/. */
5 "use strict";
7 const flags = require("resource://devtools/shared/flags.js");
8 const { ELLIPSIS } = require("resource://devtools/shared/l10n.js");
9 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
11 loader.lazyRequireGetter(
12   this,
13   "KeyShortcuts",
14   "resource://devtools/client/shared/key-shortcuts.js"
17 const MAX_LABEL_LENGTH = 40;
19 const NS_XHTML = "http://www.w3.org/1999/xhtml";
20 const SCROLL_REPEAT_MS = 100;
22 // Some margin may be required for visible element detection.
23 const SCROLL_MARGIN = 1;
25 const SHADOW_ROOT_TAGNAME = "#shadow-root";
27 /**
28  * Component to replicate functionality of XUL arrowscrollbox
29  * for breadcrumbs
30  *
31  * @param {Window} win The window containing the breadcrumbs
32  * @parem {DOMNode} container The element in which to put the scroll box
33  */
34 function ArrowScrollBox(win, container) {
35   this.win = win;
36   this.doc = win.document;
37   this.container = container;
38   EventEmitter.decorate(this);
39   this.init();
42 ArrowScrollBox.prototype = {
43   // Scroll behavior, exposed for testing
44   scrollBehavior: "smooth",
46   /**
47    * Build the HTML, add to the DOM and start listening to
48    * events
49    */
50   init() {
51     this.constructHtml();
53     this.onScroll = this.onScroll.bind(this);
54     this.onStartBtnClick = this.onStartBtnClick.bind(this);
55     this.onEndBtnClick = this.onEndBtnClick.bind(this);
56     this.onStartBtnDblClick = this.onStartBtnDblClick.bind(this);
57     this.onEndBtnDblClick = this.onEndBtnDblClick.bind(this);
58     this.onUnderflow = this.onUnderflow.bind(this);
59     this.onOverflow = this.onOverflow.bind(this);
61     this.inner.addEventListener("scroll", this.onScroll);
62     this.startBtn.addEventListener("mousedown", this.onStartBtnClick);
63     this.endBtn.addEventListener("mousedown", this.onEndBtnClick);
64     this.startBtn.addEventListener("dblclick", this.onStartBtnDblClick);
65     this.endBtn.addEventListener("dblclick", this.onEndBtnDblClick);
67     // Overflow and underflow are moz specific events
68     this.inner.addEventListener("underflow", this.onUnderflow);
69     this.inner.addEventListener("overflow", this.onOverflow);
70   },
72   /**
73    * Scroll to the specified element using the current scroll behavior
74    * @param {Element} element element to scroll
75    * @param {String} block desired alignment of element after scrolling
76    */
77   scrollToElement(element, block) {
78     element.scrollIntoView({ block, behavior: this.scrollBehavior });
79   },
81   /**
82    * Call the given function once; then continuously
83    * while the mouse button is held
84    * @param {Function} repeatFn the function to repeat while the button is held
85    */
86   clickOrHold(repeatFn) {
87     let timer;
88     const container = this.container;
90     function handleClick() {
91       cancelHold();
92       repeatFn();
93     }
95     const window = this.win;
96     function cancelHold() {
97       window.clearTimeout(timer);
98       container.removeEventListener("mouseout", cancelHold);
99       container.removeEventListener("mouseup", handleClick);
100     }
102     function repeated() {
103       repeatFn();
104       timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
105     }
107     container.addEventListener("mouseout", cancelHold);
108     container.addEventListener("mouseup", handleClick);
109     timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
110   },
112   /**
113    * When start button is dbl clicked scroll to first element
114    */
115   onStartBtnDblClick() {
116     const children = this.inner.childNodes;
117     if (children.length < 1) {
118       return;
119     }
121     const element = this.inner.childNodes[0];
122     this.scrollToElement(element, "start");
123   },
125   /**
126    * When end button is dbl clicked scroll to last element
127    */
128   onEndBtnDblClick() {
129     const children = this.inner.childNodes;
130     if (children.length < 1) {
131       return;
132     }
134     const element = children[children.length - 1];
135     this.scrollToElement(element, "start");
136   },
138   /**
139    * When start arrow button is clicked scroll towards first element
140    */
141   onStartBtnClick() {
142     const scrollToStart = () => {
143       const element = this.getFirstInvisibleElement();
144       if (!element) {
145         return;
146       }
148       this.scrollToElement(element, "start");
149     };
151     this.clickOrHold(scrollToStart);
152   },
154   /**
155    * When end arrow button is clicked scroll towards last element
156    */
157   onEndBtnClick() {
158     const scrollToEnd = () => {
159       const element = this.getLastInvisibleElement();
160       if (!element) {
161         return;
162       }
164       this.scrollToElement(element, "end");
165     };
167     this.clickOrHold(scrollToEnd);
168   },
170   /**
171    * Event handler for scrolling, update the
172    * enabled/disabled status of the arrow buttons
173    */
174   onScroll() {
175     const first = this.getFirstInvisibleElement();
176     if (!first) {
177       this.startBtn.setAttribute("disabled", "true");
178     } else {
179       this.startBtn.removeAttribute("disabled");
180     }
182     const last = this.getLastInvisibleElement();
183     if (!last) {
184       this.endBtn.setAttribute("disabled", "true");
185     } else {
186       this.endBtn.removeAttribute("disabled");
187     }
188   },
190   /**
191    * On underflow, make the arrow buttons invisible
192    */
193   onUnderflow() {
194     this.startBtn.style.visibility = "collapse";
195     this.endBtn.style.visibility = "collapse";
196     this.emit("underflow");
197   },
199   /**
200    * On overflow, show the arrow buttons
201    */
202   onOverflow() {
203     this.startBtn.style.visibility = "visible";
204     this.endBtn.style.visibility = "visible";
205     this.emit("overflow");
206   },
208   /**
209    * Check whether the element is to the left of its container but does
210    * not also span the entire container.
211    * @param {Number} left the left scroll point of the container
212    * @param {Number} right the right edge of the container
213    * @param {Number} elementLeft the left edge of the element
214    * @param {Number} elementRight the right edge of the element
215    */
216   elementLeftOfContainer(left, right, elementLeft, elementRight) {
217     return (
218       elementLeft < left - SCROLL_MARGIN && elementRight < right - SCROLL_MARGIN
219     );
220   },
222   /**
223    * Check whether the element is to the right of its container but does
224    * not also span the entire container.
225    * @param {Number} left the left scroll point of the container
226    * @param {Number} right the right edge of the container
227    * @param {Number} elementLeft the left edge of the element
228    * @param {Number} elementRight the right edge of the element
229    */
230   elementRightOfContainer(left, right, elementLeft, elementRight) {
231     return (
232       elementLeft > left + SCROLL_MARGIN && elementRight > right + SCROLL_MARGIN
233     );
234   },
236   /**
237    * Get the first (i.e. furthest left for LTR)
238    * non or partly visible element in the scroll box
239    */
240   getFirstInvisibleElement() {
241     const elementsList = Array.from(this.inner.childNodes).reverse();
243     const predicate = this.elementLeftOfContainer;
244     return this.findFirstWithBounds(elementsList, predicate);
245   },
247   /**
248    * Get the last (i.e. furthest right for LTR)
249    * non or partly visible element in the scroll box
250    */
251   getLastInvisibleElement() {
252     const predicate = this.elementRightOfContainer;
253     return this.findFirstWithBounds(this.inner.childNodes, predicate);
254   },
256   /**
257    * Find the first element that matches the given predicate, called with bounds
258    * information
259    * @param {Array} elements an ordered list of elements
260    * @param {Function} predicate a function to be called with bounds
261    * information
262    */
263   findFirstWithBounds(elements, predicate) {
264     const left = this.inner.scrollLeft;
265     const right = left + this.inner.clientWidth;
266     for (const element of elements) {
267       const elementLeft = element.offsetLeft - element.parentElement.offsetLeft;
268       const elementRight = elementLeft + element.offsetWidth;
270       // Check that the starting edge of the element is out of the visible area
271       // and that the ending edge does not span the whole container
272       if (predicate(left, right, elementLeft, elementRight)) {
273         return element;
274       }
275     }
277     return null;
278   },
280   /**
281    * Build the HTML for the scroll box and insert it into the DOM
282    */
283   constructHtml() {
284     this.startBtn = this.createElement(
285       "div",
286       "scrollbutton-up",
287       this.container
288     );
289     this.createElement("div", "toolbarbutton-icon", this.startBtn);
291     this.createElement(
292       "div",
293       "arrowscrollbox-overflow-start-indicator",
294       this.container
295     );
296     this.inner = this.createElement(
297       "div",
298       "html-arrowscrollbox-inner",
299       this.container
300     );
301     this.createElement(
302       "div",
303       "arrowscrollbox-overflow-end-indicator",
304       this.container
305     );
307     this.endBtn = this.createElement(
308       "div",
309       "scrollbutton-down",
310       this.container
311     );
312     this.createElement("div", "toolbarbutton-icon", this.endBtn);
313   },
315   /**
316    * Create an XHTML element with the given class name, and append it to the
317    * parent.
318    * @param {String} tagName name of the tag to create
319    * @param {String} className class of the element
320    * @param {DOMNode} parent the parent node to which it should be appended
321    * @return {DOMNode} The new element
322    */
323   createElement(tagName, className, parent) {
324     const el = this.doc.createElementNS(NS_XHTML, tagName);
325     el.className = className;
326     if (parent) {
327       parent.appendChild(el);
328     }
330     return el;
331   },
333   /**
334    * Remove event handlers and clean up
335    */
336   destroy() {
337     this.inner.removeEventListener("scroll", this.onScroll);
338     this.startBtn.removeEventListener("mousedown", this.onStartBtnClick);
339     this.endBtn.removeEventListener("mousedown", this.onEndBtnClick);
340     this.startBtn.removeEventListener("dblclick", this.onStartBtnDblClick);
341     this.endBtn.removeEventListener("dblclick", this.onRightBtnDblClick);
343     // Overflow and underflow are moz specific events
344     this.inner.removeEventListener("underflow", this.onUnderflow);
345     this.inner.removeEventListener("overflow", this.onOverflow);
346   },
350  * Display the ancestors of the current node and its children.
351  * Only one "branch" of children are displayed (only one line).
353  * Mechanism:
354  * - If no nodes displayed yet:
355  *   then display the ancestor of the selected node and the selected node;
356  *   else select the node;
357  * - If the selected node is the last node displayed, append its first (if any).
359  * @param {InspectorPanel} inspector The inspector hosting this widget.
360  */
361 function HTMLBreadcrumbs(inspector) {
362   this.inspector = inspector;
363   this.selection = this.inspector.selection;
364   this.win = this.inspector.panelWin;
365   this.doc = this.inspector.panelDoc;
366   this._init();
369 exports.HTMLBreadcrumbs = HTMLBreadcrumbs;
371 HTMLBreadcrumbs.prototype = {
372   get walker() {
373     return this.inspector.walker;
374   },
376   _init() {
377     this.outer = this.doc.getElementById("inspector-breadcrumbs");
378     this.arrowScrollBox = new ArrowScrollBox(this.win, this.outer);
380     this.container = this.arrowScrollBox.inner;
381     this.scroll = this.scroll.bind(this);
382     this.arrowScrollBox.on("overflow", this.scroll);
384     this.outer.addEventListener("click", this, true);
385     this.outer.addEventListener("mouseover", this, true);
386     this.outer.addEventListener("mouseout", this, true);
387     this.outer.addEventListener("focus", this, true);
389     this.handleShortcut = this.handleShortcut.bind(this);
391     if (flags.testing) {
392       // In tests, we start listening immediately to avoid having to simulate a focus.
393       this.initKeyShortcuts();
394     } else {
395       this.outer.addEventListener(
396         "focus",
397         () => {
398           this.initKeyShortcuts();
399         },
400         { once: true }
401       );
402     }
404     // We will save a list of already displayed nodes in this array.
405     this.nodeHierarchy = [];
407     // Last selected node in nodeHierarchy.
408     this.currentIndex = -1;
410     // Used to build a unique breadcrumb button Id.
411     this.breadcrumbsWidgetItemId = 0;
413     this.update = this.update.bind(this);
414     this.updateWithMutations = this.updateWithMutations.bind(this);
415     this.updateSelectors = this.updateSelectors.bind(this);
416     this.selection.on("new-node-front", this.update);
417     this.selection.on("pseudoclass", this.updateSelectors);
418     this.selection.on("attribute-changed", this.updateSelectors);
419     this.inspector.on("markupmutation", this.updateWithMutations);
420     this.update();
421   },
423   initKeyShortcuts() {
424     this.shortcuts = new KeyShortcuts({ window: this.win, target: this.outer });
425     this.shortcuts.on("Right", this.handleShortcut);
426     this.shortcuts.on("Left", this.handleShortcut);
427   },
429   /**
430    * Build a string that represents the node: tagName#id.class1.class2.
431    * @param {NodeFront} node The node to pretty-print
432    * @return {String}
433    */
434   prettyPrintNodeAsText(node) {
435     let text = node.isShadowRoot ? SHADOW_ROOT_TAGNAME : node.displayName;
436     if (node.isMarkerPseudoElement) {
437       text = "::marker";
438     } else if (node.isBeforePseudoElement) {
439       text = "::before";
440     } else if (node.isAfterPseudoElement) {
441       text = "::after";
442     }
444     if (node.id) {
445       text += "#" + node.id;
446     }
448     if (node.className) {
449       const classList = node.className.split(/\s+/);
450       for (let i = 0; i < classList.length; i++) {
451         text += "." + classList[i];
452       }
453     }
455     for (const pseudo of node.pseudoClassLocks) {
456       text += pseudo;
457     }
459     return text;
460   },
462   /**
463    * Build <span>s that represent the node:
464    *   <span class="breadcrumbs-widget-item-tag">tagName</span>
465    *   <span class="breadcrumbs-widget-item-id">#id</span>
466    *   <span class="breadcrumbs-widget-item-classes">.class1.class2</span>
467    * @param {NodeFront} node The node to pretty-print
468    * @returns {DocumentFragment}
469    */
470   prettyPrintNodeAsXHTML(node) {
471     const tagLabel = this.doc.createElementNS(NS_XHTML, "span");
472     tagLabel.className = "breadcrumbs-widget-item-tag plain";
474     const idLabel = this.doc.createElementNS(NS_XHTML, "span");
475     idLabel.className = "breadcrumbs-widget-item-id plain";
477     const classesLabel = this.doc.createElementNS(NS_XHTML, "span");
478     classesLabel.className = "breadcrumbs-widget-item-classes plain";
480     const pseudosLabel = this.doc.createElementNS(NS_XHTML, "span");
481     pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes plain";
483     let tagText = node.isShadowRoot ? SHADOW_ROOT_TAGNAME : node.displayName;
484     if (node.isMarkerPseudoElement) {
485       tagText = "::marker";
486     } else if (node.isBeforePseudoElement) {
487       tagText = "::before";
488     } else if (node.isAfterPseudoElement) {
489       tagText = "::after";
490     }
491     let idText = node.id ? "#" + node.id : "";
492     let classesText = "";
494     if (node.className) {
495       const classList = node.className.split(/\s+/);
496       for (let i = 0; i < classList.length; i++) {
497         classesText += "." + classList[i];
498       }
499     }
501     // Figure out which element (if any) needs ellipsing.
502     // Substring for that element, then clear out any extras
503     // (except for pseudo elements).
504     const maxTagLength = MAX_LABEL_LENGTH;
505     const maxIdLength = MAX_LABEL_LENGTH - tagText.length;
506     const maxClassLength = MAX_LABEL_LENGTH - tagText.length - idText.length;
508     if (tagText.length > maxTagLength) {
509       tagText = tagText.substr(0, maxTagLength) + ELLIPSIS;
510       idText = classesText = "";
511     } else if (idText.length > maxIdLength) {
512       idText = idText.substr(0, maxIdLength) + ELLIPSIS;
513       classesText = "";
514     } else if (classesText.length > maxClassLength) {
515       classesText = classesText.substr(0, maxClassLength) + ELLIPSIS;
516     }
518     tagLabel.textContent = tagText;
519     idLabel.textContent = idText;
520     classesLabel.textContent = classesText;
521     pseudosLabel.textContent = node.pseudoClassLocks.join("");
523     const fragment = this.doc.createDocumentFragment();
524     fragment.appendChild(tagLabel);
525     fragment.appendChild(idLabel);
526     fragment.appendChild(classesLabel);
527     fragment.appendChild(pseudosLabel);
529     return fragment;
530   },
532   /**
533    * Generic event handler.
534    * @param {DOMEvent} event.
535    */
536   handleEvent(event) {
537     if (event.type == "click" && event.button == 0) {
538       this.handleClick(event);
539     } else if (event.type == "mouseover") {
540       this.handleMouseOver(event);
541     } else if (event.type == "mouseout") {
542       this.handleMouseOut(event);
543     } else if (event.type == "focus") {
544       this.handleFocus(event);
545     }
546   },
548   /**
549    * Focus event handler. When breadcrumbs container gets focus,
550    * aria-activedescendant needs to be updated to currently selected
551    * breadcrumb. Ensures that the focus stays on the container at all times.
552    * @param {DOMEvent} event.
553    */
554   handleFocus(event) {
555     event.stopPropagation();
557     const node = this.nodeHierarchy[this.currentIndex];
558     if (node) {
559       this.outer.setAttribute("aria-activedescendant", node.button.id);
560     } else {
561       this.outer.removeAttribute("aria-activedescendant");
562     }
564     this.outer.focus();
565   },
567   /**
568    * On click navigate to the correct node.
569    * @param {DOMEvent} event.
570    */
571   handleClick(event) {
572     const target = event.originalTarget;
573     if (target.tagName == "button") {
574       target.onBreadcrumbsClick();
575     }
576   },
578   /**
579    * On mouse over, highlight the corresponding content DOM Node.
580    * @param {DOMEvent} event.
581    */
582   handleMouseOver(event) {
583     const target = event.originalTarget;
584     if (target.tagName == "button") {
585       target.onBreadcrumbsHover();
586     }
587   },
589   /**
590    * On mouse out, make sure to unhighlight.
591    */
592   handleMouseOut() {
593     this.inspector.highlighters.hideHighlighterType(
594       this.inspector.highlighters.TYPES.BOXMODEL
595     );
596   },
598   /**
599    * Handle a keyboard shortcut supported by the breadcrumbs widget.
600    *
601    * @param {String} name
602    *        Name of the keyboard shortcut received.
603    * @param {DOMEvent} event
604    *        Original event that triggered the shortcut.
605    */
606   handleShortcut(event) {
607     if (!this.selection.isElementNode()) {
608       return;
609     }
611     event.preventDefault();
612     event.stopPropagation();
614     this.keyPromise = (this.keyPromise || Promise.resolve(null)).then(() => {
615       let currentnode;
617       const isLeft = event.code === "ArrowLeft";
618       const isRight = event.code === "ArrowRight";
620       if (isLeft && this.currentIndex != 0) {
621         currentnode = this.nodeHierarchy[this.currentIndex - 1];
622       } else if (isRight && this.currentIndex < this.nodeHierarchy.length - 1) {
623         currentnode = this.nodeHierarchy[this.currentIndex + 1];
624       } else {
625         return null;
626       }
628       this.outer.setAttribute("aria-activedescendant", currentnode.button.id);
629       return this.selection.setNodeFront(currentnode.node, {
630         reason: "breadcrumbs",
631       });
632     });
633   },
635   /**
636    * Remove nodes and clean up.
637    */
638   destroy() {
639     this.selection.off("new-node-front", this.update);
640     this.selection.off("pseudoclass", this.updateSelectors);
641     this.selection.off("attribute-changed", this.updateSelectors);
642     this.inspector.off("markupmutation", this.updateWithMutations);
644     this.container.removeEventListener("click", this, true);
645     this.container.removeEventListener("mouseover", this, true);
646     this.container.removeEventListener("mouseout", this, true);
647     this.container.removeEventListener("focus", this, true);
649     if (this.shortcuts) {
650       this.shortcuts.destroy();
651     }
653     this.empty();
655     this.arrowScrollBox.off("overflow", this.scroll);
656     this.arrowScrollBox.destroy();
657     this.arrowScrollBox = null;
658     this.outer = null;
659     this.container = null;
660     this.nodeHierarchy = null;
662     this.isDestroyed = true;
663   },
665   /**
666    * Empty the breadcrumbs container.
667    */
668   empty() {
669     while (this.container.hasChildNodes()) {
670       this.container.firstChild.remove();
671     }
672   },
674   /**
675    * Set which button represent the selected node.
676    * @param {Number} index Index of the displayed-button to select.
677    */
678   setCursor(index) {
679     // Unselect the previously selected button
680     if (
681       this.currentIndex > -1 &&
682       this.currentIndex < this.nodeHierarchy.length
683     ) {
684       this.nodeHierarchy[this.currentIndex].button.removeAttribute("checked");
685     }
686     if (index > -1) {
687       this.nodeHierarchy[index].button.setAttribute("checked", "true");
688     } else {
689       // Unset active active descendant when all buttons are unselected.
690       this.outer.removeAttribute("aria-activedescendant");
691     }
692     this.currentIndex = index;
693   },
695   /**
696    * Get the index of the node in the cache.
697    * @param {NodeFront} node.
698    * @returns {Number} The index for this node or -1 if not found.
699    */
700   indexOf(node) {
701     for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
702       if (this.nodeHierarchy[i].node === node) {
703         return i;
704       }
705     }
706     return -1;
707   },
709   /**
710    * Remove all the buttons and their references in the cache after a given
711    * index.
712    * @param {Number} index.
713    */
714   cutAfter(index) {
715     while (this.nodeHierarchy.length > index + 1) {
716       const toRemove = this.nodeHierarchy.pop();
717       this.container.removeChild(toRemove.button);
718     }
719   },
721   /**
722    * Build a button representing the node.
723    * @param {NodeFront} node The node from the page.
724    * @return {DOMNode} The <button> for this node.
725    */
726   buildButton(node) {
727     const button = this.doc.createElementNS(NS_XHTML, "button");
728     button.appendChild(this.prettyPrintNodeAsXHTML(node));
729     button.className = "breadcrumbs-widget-item";
730     button.id = "breadcrumbs-widget-item-" + this.breadcrumbsWidgetItemId++;
732     button.setAttribute("tabindex", "-1");
733     button.setAttribute("title", this.prettyPrintNodeAsText(node));
735     button.onclick = () => {
736       button.focus();
737     };
739     button.onBreadcrumbsClick = () => {
740       this.selection.setNodeFront(node, { reason: "breadcrumbs" });
741     };
743     button.onBreadcrumbsHover = () => {
744       this.inspector.highlighters.showHighlighterTypeForNode(
745         this.inspector.highlighters.TYPES.BOXMODEL,
746         node
747       );
748     };
750     return button;
751   },
753   /**
754    * Connecting the end of the breadcrumbs to a node.
755    * @param {NodeFront} node The node to reach.
756    */
757   expand(node) {
758     const fragment = this.doc.createDocumentFragment();
759     let lastButtonInserted = null;
760     const originalLength = this.nodeHierarchy.length;
761     let stopNode = null;
762     if (originalLength > 0) {
763       stopNode = this.nodeHierarchy[originalLength - 1].node;
764     }
765     while (node && node != stopNode) {
766       if (node.tagName || node.isShadowRoot) {
767         const button = this.buildButton(node);
768         fragment.insertBefore(button, lastButtonInserted);
769         lastButtonInserted = button;
770         this.nodeHierarchy.splice(originalLength, 0, {
771           node,
772           button,
773           currentPrettyPrintText: this.prettyPrintNodeAsText(node),
774         });
775       }
776       node = node.parentOrHost();
777     }
778     this.container.appendChild(fragment, this.container.firstChild);
779   },
781   /**
782    * Find the "youngest" ancestor of a node which is already in the breadcrumbs.
783    * @param {NodeFront} node.
784    * @return {Number} Index of the ancestor in the cache, or -1 if not found.
785    */
786   getCommonAncestor(node) {
787     while (node) {
788       const idx = this.indexOf(node);
789       if (idx > -1) {
790         return idx;
791       }
792       node = node.parentNode();
793     }
794     return -1;
795   },
797   /**
798    * Ensure the selected node is visible.
799    */
800   scroll() {
801     // FIXME bug 684352: make sure its immediate neighbors are visible too.
802     if (!this.isDestroyed) {
803       const element = this.nodeHierarchy[this.currentIndex].button;
804       this.arrowScrollBox.scrollToElement(element, "end");
805     }
806   },
808   /**
809    * Update all button outputs.
810    */
811   updateSelectors() {
812     if (this.isDestroyed) {
813       return;
814     }
816     for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
817       const { node, button, currentPrettyPrintText } = this.nodeHierarchy[i];
819       // If the output of the node doesn't change, skip the update.
820       const textOutput = this.prettyPrintNodeAsText(node);
821       if (currentPrettyPrintText === textOutput) {
822         continue;
823       }
825       // Otherwise, update the whole markup for the button.
826       while (button.hasChildNodes()) {
827         button.firstChild.remove();
828       }
829       button.appendChild(this.prettyPrintNodeAsXHTML(node));
830       button.setAttribute("title", textOutput);
832       this.nodeHierarchy[i].currentPrettyPrintText = textOutput;
833     }
834   },
836   /**
837    * Given a list of mutation changes (passed by the markupmutation event),
838    * decide whether or not they are "interesting" to the current state of the
839    * breadcrumbs widget, i.e. at least one of them should cause part of the
840    * widget to be updated.
841    * @param {Array} mutations The mutations array.
842    * @return {Boolean}
843    */
844   _hasInterestingMutations(mutations) {
845     if (!mutations || !mutations.length) {
846       return false;
847     }
849     for (const { type, added, removed, target, attributeName } of mutations) {
850       if (type === "childList") {
851         // Only interested in childList mutations if the added or removed
852         // nodes are currently displayed.
853         return (
854           added.some(node => this.indexOf(node) > -1) ||
855           removed.some(node => this.indexOf(node) > -1)
856         );
857       } else if (type === "attributes" && this.indexOf(target) > -1) {
858         // Only interested in attributes mutations if the target is
859         // currently displayed, and the attribute is either id or class.
860         return attributeName === "class" || attributeName === "id";
861       }
862     }
864     // Catch all return in case the mutations array was empty, or in case none
865     // of the changes iterated above were interesting.
866     return false;
867   },
869   /**
870    * Update the breadcrumbs display when a new node is selected and there are
871    * mutations.
872    * @param {Array} mutations An array of mutations in case this was called as
873    * the "markupmutation" event listener.
874    */
875   updateWithMutations(mutations) {
876     return this.update("markupmutation", mutations);
877   },
879   /**
880    * Update the breadcrumbs display when a new node is selected.
881    * @param {String} reason The reason for the update, if any.
882    * @param {Array} mutations An array of mutations in case this was called as
883    * the "markupmutation" event listener.
884    */
885   update(reason, mutations) {
886     if (this.isDestroyed) {
887       return;
888     }
890     const hasInterestingMutations = this._hasInterestingMutations(mutations);
891     if (reason === "markupmutation" && !hasInterestingMutations) {
892       return;
893     }
895     if (!this.selection.isConnected()) {
896       // remove all the crumbs
897       this.cutAfter(-1);
898       return;
899     }
901     // If this was an interesting deletion; then trim the breadcrumb trail
902     let trimmed = false;
903     if (reason === "markupmutation") {
904       for (const { type, removed } of mutations) {
905         if (type !== "childList") {
906           continue;
907         }
909         for (const node of removed) {
910           const removedIndex = this.indexOf(node);
911           if (removedIndex > -1) {
912             this.cutAfter(removedIndex - 1);
913             trimmed = true;
914           }
915         }
916       }
917     }
919     if (!this.selection.isElementNode() && !this.selection.isShadowRootNode()) {
920       // no selection
921       this.setCursor(-1);
922       if (trimmed) {
923         // Since something changed, notify the interested parties.
924         this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
925       }
926       return;
927     }
929     let idx = this.indexOf(this.selection.nodeFront);
931     // Is the node already displayed in the breadcrumbs?
932     // (and there are no mutations that need re-display of the crumbs)
933     if (idx > -1 && !hasInterestingMutations) {
934       // Yes. We select it.
935       this.setCursor(idx);
936     } else {
937       // No. Is the breadcrumbs display empty?
938       if (this.nodeHierarchy.length) {
939         // No. We drop all the element that are not direct ancestors
940         // of the selection
941         const parent = this.selection.nodeFront.parentNode();
942         const ancestorIdx = this.getCommonAncestor(parent);
943         this.cutAfter(ancestorIdx);
944       }
945       // we append the missing button between the end of the breadcrumbs display
946       // and the current node.
947       this.expand(this.selection.nodeFront);
949       // we select the current node button
950       idx = this.indexOf(this.selection.nodeFront);
951       this.setCursor(idx);
952     }
954     const doneUpdating = this.inspector.updating("breadcrumbs");
956     this.updateSelectors();
958     // Make sure the selected node and its neighbours are visible.
959     setTimeout(() => {
960       try {
961         this.scroll();
962         this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
963         doneUpdating();
964       } catch (e) {
965         // Only log this as an error if we haven't been destroyed in the meantime.
966         if (!this.isDestroyed) {
967           console.error(e);
968         }
969       }
970     }, 0);
971   },