tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

breadcrumbs.js (28974B)


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