Bug 1854550 - pt 12. Allow inlining between mozjemalloc and PHC r=glandium
[gecko.git] / devtools / client / inspector / breadcrumbs.js
blob68bafb591c8de1d4fdbed6afaf254ae816c8feeb
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    * @param {DOMEvent} event.
592    */
593   handleMouseOut(event) {
594     this.inspector.highlighters.hideHighlighterType(
595       this.inspector.highlighters.TYPES.BOXMODEL
596     );
597   },
599   /**
600    * Handle a keyboard shortcut supported by the breadcrumbs widget.
601    *
602    * @param {String} name
603    *        Name of the keyboard shortcut received.
604    * @param {DOMEvent} event
605    *        Original event that triggered the shortcut.
606    */
607   handleShortcut(event) {
608     if (!this.selection.isElementNode()) {
609       return;
610     }
612     event.preventDefault();
613     event.stopPropagation();
615     this.keyPromise = (this.keyPromise || Promise.resolve(null)).then(() => {
616       let currentnode;
618       const isLeft = event.code === "ArrowLeft";
619       const isRight = event.code === "ArrowRight";
621       if (isLeft && this.currentIndex != 0) {
622         currentnode = this.nodeHierarchy[this.currentIndex - 1];
623       } else if (isRight && this.currentIndex < this.nodeHierarchy.length - 1) {
624         currentnode = this.nodeHierarchy[this.currentIndex + 1];
625       } else {
626         return null;
627       }
629       this.outer.setAttribute("aria-activedescendant", currentnode.button.id);
630       return this.selection.setNodeFront(currentnode.node, {
631         reason: "breadcrumbs",
632       });
633     });
634   },
636   /**
637    * Remove nodes and clean up.
638    */
639   destroy() {
640     this.selection.off("new-node-front", this.update);
641     this.selection.off("pseudoclass", this.updateSelectors);
642     this.selection.off("attribute-changed", this.updateSelectors);
643     this.inspector.off("markupmutation", this.updateWithMutations);
645     this.container.removeEventListener("click", this, true);
646     this.container.removeEventListener("mouseover", this, true);
647     this.container.removeEventListener("mouseout", this, true);
648     this.container.removeEventListener("focus", this, true);
650     if (this.shortcuts) {
651       this.shortcuts.destroy();
652     }
654     this.empty();
656     this.arrowScrollBox.off("overflow", this.scroll);
657     this.arrowScrollBox.destroy();
658     this.arrowScrollBox = null;
659     this.outer = null;
660     this.container = null;
661     this.nodeHierarchy = null;
663     this.isDestroyed = true;
664   },
666   /**
667    * Empty the breadcrumbs container.
668    */
669   empty() {
670     while (this.container.hasChildNodes()) {
671       this.container.firstChild.remove();
672     }
673   },
675   /**
676    * Set which button represent the selected node.
677    * @param {Number} index Index of the displayed-button to select.
678    */
679   setCursor(index) {
680     // Unselect the previously selected button
681     if (
682       this.currentIndex > -1 &&
683       this.currentIndex < this.nodeHierarchy.length
684     ) {
685       this.nodeHierarchy[this.currentIndex].button.removeAttribute("checked");
686     }
687     if (index > -1) {
688       this.nodeHierarchy[index].button.setAttribute("checked", "true");
689     } else {
690       // Unset active active descendant when all buttons are unselected.
691       this.outer.removeAttribute("aria-activedescendant");
692     }
693     this.currentIndex = index;
694   },
696   /**
697    * Get the index of the node in the cache.
698    * @param {NodeFront} node.
699    * @returns {Number} The index for this node or -1 if not found.
700    */
701   indexOf(node) {
702     for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
703       if (this.nodeHierarchy[i].node === node) {
704         return i;
705       }
706     }
707     return -1;
708   },
710   /**
711    * Remove all the buttons and their references in the cache after a given
712    * index.
713    * @param {Number} index.
714    */
715   cutAfter(index) {
716     while (this.nodeHierarchy.length > index + 1) {
717       const toRemove = this.nodeHierarchy.pop();
718       this.container.removeChild(toRemove.button);
719     }
720   },
722   /**
723    * Build a button representing the node.
724    * @param {NodeFront} node The node from the page.
725    * @return {DOMNode} The <button> for this node.
726    */
727   buildButton(node) {
728     const button = this.doc.createElementNS(NS_XHTML, "button");
729     button.appendChild(this.prettyPrintNodeAsXHTML(node));
730     button.className = "breadcrumbs-widget-item";
731     button.id = "breadcrumbs-widget-item-" + this.breadcrumbsWidgetItemId++;
733     button.setAttribute("tabindex", "-1");
734     button.setAttribute("title", this.prettyPrintNodeAsText(node));
736     button.onclick = () => {
737       button.focus();
738     };
740     button.onBreadcrumbsClick = () => {
741       this.selection.setNodeFront(node, { reason: "breadcrumbs" });
742     };
744     button.onBreadcrumbsHover = () => {
745       this.inspector.highlighters.showHighlighterTypeForNode(
746         this.inspector.highlighters.TYPES.BOXMODEL,
747         node
748       );
749     };
751     return button;
752   },
754   /**
755    * Connecting the end of the breadcrumbs to a node.
756    * @param {NodeFront} node The node to reach.
757    */
758   expand(node) {
759     const fragment = this.doc.createDocumentFragment();
760     let lastButtonInserted = null;
761     const originalLength = this.nodeHierarchy.length;
762     let stopNode = null;
763     if (originalLength > 0) {
764       stopNode = this.nodeHierarchy[originalLength - 1].node;
765     }
766     while (node && node != stopNode) {
767       if (node.tagName || node.isShadowRoot) {
768         const button = this.buildButton(node);
769         fragment.insertBefore(button, lastButtonInserted);
770         lastButtonInserted = button;
771         this.nodeHierarchy.splice(originalLength, 0, {
772           node,
773           button,
774           currentPrettyPrintText: this.prettyPrintNodeAsText(node),
775         });
776       }
777       node = node.parentOrHost();
778     }
779     this.container.appendChild(fragment, this.container.firstChild);
780   },
782   /**
783    * Find the "youngest" ancestor of a node which is already in the breadcrumbs.
784    * @param {NodeFront} node.
785    * @return {Number} Index of the ancestor in the cache, or -1 if not found.
786    */
787   getCommonAncestor(node) {
788     while (node) {
789       const idx = this.indexOf(node);
790       if (idx > -1) {
791         return idx;
792       }
793       node = node.parentNode();
794     }
795     return -1;
796   },
798   /**
799    * Ensure the selected node is visible.
800    */
801   scroll() {
802     // FIXME bug 684352: make sure its immediate neighbors are visible too.
803     if (!this.isDestroyed) {
804       const element = this.nodeHierarchy[this.currentIndex].button;
805       this.arrowScrollBox.scrollToElement(element, "end");
806     }
807   },
809   /**
810    * Update all button outputs.
811    */
812   updateSelectors() {
813     if (this.isDestroyed) {
814       return;
815     }
817     for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
818       const { node, button, currentPrettyPrintText } = this.nodeHierarchy[i];
820       // If the output of the node doesn't change, skip the update.
821       const textOutput = this.prettyPrintNodeAsText(node);
822       if (currentPrettyPrintText === textOutput) {
823         continue;
824       }
826       // Otherwise, update the whole markup for the button.
827       while (button.hasChildNodes()) {
828         button.firstChild.remove();
829       }
830       button.appendChild(this.prettyPrintNodeAsXHTML(node));
831       button.setAttribute("title", textOutput);
833       this.nodeHierarchy[i].currentPrettyPrintText = textOutput;
834     }
835   },
837   /**
838    * Given a list of mutation changes (passed by the markupmutation event),
839    * decide whether or not they are "interesting" to the current state of the
840    * breadcrumbs widget, i.e. at least one of them should cause part of the
841    * widget to be updated.
842    * @param {Array} mutations The mutations array.
843    * @return {Boolean}
844    */
845   _hasInterestingMutations(mutations) {
846     if (!mutations || !mutations.length) {
847       return false;
848     }
850     for (const { type, added, removed, target, attributeName } of mutations) {
851       if (type === "childList") {
852         // Only interested in childList mutations if the added or removed
853         // nodes are currently displayed.
854         return (
855           added.some(node => this.indexOf(node) > -1) ||
856           removed.some(node => this.indexOf(node) > -1)
857         );
858       } else if (type === "attributes" && this.indexOf(target) > -1) {
859         // Only interested in attributes mutations if the target is
860         // currently displayed, and the attribute is either id or class.
861         return attributeName === "class" || attributeName === "id";
862       }
863     }
865     // Catch all return in case the mutations array was empty, or in case none
866     // of the changes iterated above were interesting.
867     return false;
868   },
870   /**
871    * Update the breadcrumbs display when a new node is selected and there are
872    * mutations.
873    * @param {Array} mutations An array of mutations in case this was called as
874    * the "markupmutation" event listener.
875    */
876   updateWithMutations(mutations) {
877     return this.update("markupmutation", mutations);
878   },
880   /**
881    * Update the breadcrumbs display when a new node is selected.
882    * @param {String} reason The reason for the update, if any.
883    * @param {Array} mutations An array of mutations in case this was called as
884    * the "markupmutation" event listener.
885    */
886   update(reason, mutations) {
887     if (this.isDestroyed) {
888       return;
889     }
891     const hasInterestingMutations = this._hasInterestingMutations(mutations);
892     if (reason === "markupmutation" && !hasInterestingMutations) {
893       return;
894     }
896     if (!this.selection.isConnected()) {
897       // remove all the crumbs
898       this.cutAfter(-1);
899       return;
900     }
902     // If this was an interesting deletion; then trim the breadcrumb trail
903     let trimmed = false;
904     if (reason === "markupmutation") {
905       for (const { type, removed } of mutations) {
906         if (type !== "childList") {
907           continue;
908         }
910         for (const node of removed) {
911           const removedIndex = this.indexOf(node);
912           if (removedIndex > -1) {
913             this.cutAfter(removedIndex - 1);
914             trimmed = true;
915           }
916         }
917       }
918     }
920     if (!this.selection.isElementNode() && !this.selection.isShadowRootNode()) {
921       // no selection
922       this.setCursor(-1);
923       if (trimmed) {
924         // Since something changed, notify the interested parties.
925         this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
926       }
927       return;
928     }
930     let idx = this.indexOf(this.selection.nodeFront);
932     // Is the node already displayed in the breadcrumbs?
933     // (and there are no mutations that need re-display of the crumbs)
934     if (idx > -1 && !hasInterestingMutations) {
935       // Yes. We select it.
936       this.setCursor(idx);
937     } else {
938       // No. Is the breadcrumbs display empty?
939       if (this.nodeHierarchy.length) {
940         // No. We drop all the element that are not direct ancestors
941         // of the selection
942         const parent = this.selection.nodeFront.parentNode();
943         const ancestorIdx = this.getCommonAncestor(parent);
944         this.cutAfter(ancestorIdx);
945       }
946       // we append the missing button between the end of the breadcrumbs display
947       // and the current node.
948       this.expand(this.selection.nodeFront);
950       // we select the current node button
951       idx = this.indexOf(this.selection.nodeFront);
952       this.setCursor(idx);
953     }
955     const doneUpdating = this.inspector.updating("breadcrumbs");
957     this.updateSelectors();
959     // Make sure the selected node and its neighbours are visible.
960     setTimeout(() => {
961       try {
962         this.scroll();
963         this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
964         doneUpdating();
965       } catch (e) {
966         // Only log this as an error if we haven't been destroyed in the meantime.
967         if (!this.isDestroyed) {
968           console.error(e);
969         }
970       }
971     }, 0);
972   },