/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const { ELLIPSIS } = require("resource://devtools/shared/l10n.js");
const EventEmitter = require("resource://devtools/shared/event-emitter.js");

loader.lazyRequireGetter(
  this,
  "KeyShortcuts",
  "resource://devtools/client/shared/key-shortcuts.js"
);

const MAX_LABEL_LENGTH = 40;

const NS_XHTML = "http://www.w3.org/1999/xhtml";
const SCROLL_REPEAT_MS = 100;

// Some margin may be required for visible element detection.
const SCROLL_MARGIN = 1;

const SHADOW_ROOT_TAGNAME = "#shadow-root";

/**
 * Component to replicate functionality of XUL arrowscrollbox
 * for breadcrumbs
 */
class ArrowScrollBox extends EventEmitter {
  /**
   * @param {Window} win The window containing the breadcrumbs
   * @param {Element} container The element in which to put the scroll box
   */
  constructor(win, container) {
    super();
    this.win = win;
    this.doc = win.document;
    this.container = container;
    this.init();
  }
  // Scroll behavior, exposed for testing
  scrollBehavior = "smooth";
  /**
   * Build the HTML, add to the DOM and start listening to
   * events
   */
  init() {
    this.constructHtml();

    this.onScroll = this.onScroll.bind(this);
    this.onStartBtnClick = this.onStartBtnClick.bind(this);
    this.onEndBtnClick = this.onEndBtnClick.bind(this);
    this.onStartBtnDblClick = this.onStartBtnDblClick.bind(this);
    this.onEndBtnDblClick = this.onEndBtnDblClick.bind(this);
    this.onUnderflow = this.onUnderflow.bind(this);
    this.onOverflow = this.onOverflow.bind(this);

    this.inner.addEventListener("scroll", this.onScroll);
    this.startBtn.addEventListener("mousedown", this.onStartBtnClick);
    this.endBtn.addEventListener("mousedown", this.onEndBtnClick);
    this.startBtn.addEventListener("dblclick", this.onStartBtnDblClick);
    this.endBtn.addEventListener("dblclick", this.onEndBtnDblClick);

    // Overflow and underflow are moz specific events
    this.inner.addEventListener("underflow", this.onUnderflow);
    this.inner.addEventListener("overflow", this.onOverflow);
  }

  /**
   * Scroll to the specified element using the current scroll behavior
   *
   * @param {Element} element element to scroll
   * @param {string} block desired alignment of element after scrolling
   */
  scrollToElement(element, block) {
    element.scrollIntoView({ block, behavior: this.scrollBehavior });
  }

  /**
   * Call the given function once; then continuously
   * while the mouse button is held
   *
   * @param {Function} repeatFn the function to repeat while the button is held
   */
  clickOrHold(repeatFn) {
    let timer;
    const container = this.container;

    function handleClick() {
      cancelHold();
      repeatFn();
    }

    const window = this.win;
    function cancelHold() {
      window.clearTimeout(timer);
      container.removeEventListener("mouseout", cancelHold);
      container.removeEventListener("mouseup", handleClick);
    }

    function repeated() {
      repeatFn();
      timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
    }

    container.addEventListener("mouseout", cancelHold);
    container.addEventListener("mouseup", handleClick);
    timer = window.setTimeout(repeated, SCROLL_REPEAT_MS);
  }

  /**
   * When start button is dbl clicked scroll to first element
   */
  onStartBtnDblClick() {
    const children = this.inner.childNodes;
    if (children.length < 1) {
      return;
    }

    const element = this.inner.childNodes[0];
    this.scrollToElement(element, "start");
  }

  /**
   * When end button is dbl clicked scroll to last element
   */
  onEndBtnDblClick() {
    const children = this.inner.childNodes;
    if (children.length < 1) {
      return;
    }

    const element = children[children.length - 1];
    this.scrollToElement(element, "start");
  }

  /**
   * When start arrow button is clicked scroll towards first element
   */
  onStartBtnClick() {
    const scrollToStart = () => {
      const element = this.getFirstInvisibleElement();
      if (!element) {
        return;
      }

      this.scrollToElement(element, "start");
    };

    this.clickOrHold(scrollToStart);
  }

  /**
   * When end arrow button is clicked scroll towards last element
   */
  onEndBtnClick() {
    const scrollToEnd = () => {
      const element = this.getLastInvisibleElement();
      if (!element) {
        return;
      }

      this.scrollToElement(element, "end");
    };

    this.clickOrHold(scrollToEnd);
  }

  /**
   * Event handler for scrolling, update the
   * enabled/disabled status of the arrow buttons
   */
  onScroll() {
    const first = this.getFirstInvisibleElement();
    if (!first) {
      this.startBtn.setAttribute("disabled", "true");
    } else {
      this.startBtn.removeAttribute("disabled");
    }

    const last = this.getLastInvisibleElement();
    if (!last) {
      this.endBtn.setAttribute("disabled", "true");
    } else {
      this.endBtn.removeAttribute("disabled");
    }
  }

  /**
   * On underflow, make the arrow buttons invisible
   */
  onUnderflow() {
    this.startBtn.style.visibility = "collapse";
    this.endBtn.style.visibility = "collapse";
    this.emit("underflow");
  }

  /**
   * On overflow, show the arrow buttons
   */
  onOverflow() {
    this.startBtn.style.visibility = "visible";
    this.endBtn.style.visibility = "visible";
    this.emit("overflow");
  }

  /**
   * Check whether the element is to the left of its container but does
   * not also span the entire container.
   *
   * @param {number} left the left scroll point of the container
   * @param {number} right the right edge of the container
   * @param {number} elementLeft the left edge of the element
   * @param {number} elementRight the right edge of the element
   */
  elementLeftOfContainer(left, right, elementLeft, elementRight) {
    return (
      elementLeft < left - SCROLL_MARGIN && elementRight < right - SCROLL_MARGIN
    );
  }

  /**
   * Check whether the element is to the right of its container but does
   * not also span the entire container.
   *
   * @param {number} left the left scroll point of the container
   * @param {number} right the right edge of the container
   * @param {number} elementLeft the left edge of the element
   * @param {number} elementRight the right edge of the element
   */
  elementRightOfContainer(left, right, elementLeft, elementRight) {
    return (
      elementLeft > left + SCROLL_MARGIN && elementRight > right + SCROLL_MARGIN
    );
  }

  /**
   * Get the first (i.e. furthest left for LTR)
   * non or partly visible element in the scroll box
   */
  getFirstInvisibleElement() {
    const elementsList = Array.from(this.inner.childNodes).reverse();

    const predicate = this.elementLeftOfContainer;
    return this.findFirstWithBounds(elementsList, predicate);
  }

  /**
   * Get the last (i.e. furthest right for LTR)
   * non or partly visible element in the scroll box
   */
  getLastInvisibleElement() {
    const predicate = this.elementRightOfContainer;
    return this.findFirstWithBounds(this.inner.childNodes, predicate);
  }

  /**
   * Find the first element that matches the given predicate, called with bounds
   * information
   *
   * @param {Array} elements an ordered list of elements
   * @param {Function} predicate a function to be called with bounds
   * information
   */
  findFirstWithBounds(elements, predicate) {
    const left = this.inner.scrollLeft;
    const right = left + this.inner.clientWidth;
    for (const element of elements) {
      const elementLeft = element.offsetLeft - element.parentElement.offsetLeft;
      const elementRight = elementLeft + element.offsetWidth;

      // Check that the starting edge of the element is out of the visible area
      // and that the ending edge does not span the whole container
      if (predicate(left, right, elementLeft, elementRight)) {
        return element;
      }
    }

    return null;
  }

  /**
   * Build the HTML for the scroll box and insert it into the DOM
   */
  constructHtml() {
    this.startBtn = this.createElement(
      "div",
      "scrollbutton-up",
      this.container
    );
    this.createElement("div", "toolbarbutton-icon", this.startBtn);

    this.createElement(
      "div",
      "arrowscrollbox-overflow-start-indicator",
      this.container
    );
    this.inner = this.createElement(
      "div",
      "html-arrowscrollbox-inner",
      this.container
    );
    this.createElement(
      "div",
      "arrowscrollbox-overflow-end-indicator",
      this.container
    );

    this.endBtn = this.createElement(
      "div",
      "scrollbutton-down",
      this.container
    );
    this.createElement("div", "toolbarbutton-icon", this.endBtn);
  }

  /**
   * Create an XHTML element with the given class name, and append it to the
   * parent.
   *
   * @param {string} tagName name of the tag to create
   * @param {string} className class of the element
   * @param {Element} parent the parent node to which it should be appended
   * @return {Element} The new element
   */
  createElement(tagName, className, parent) {
    const el = this.doc.createElementNS(NS_XHTML, tagName);
    el.className = className;
    if (parent) {
      parent.appendChild(el);
    }

    return el;
  }

  /**
   * Remove event handlers and clean up
   */
  destroy() {
    this.inner.removeEventListener("scroll", this.onScroll);
    this.startBtn.removeEventListener("mousedown", this.onStartBtnClick);
    this.endBtn.removeEventListener("mousedown", this.onEndBtnClick);
    this.startBtn.removeEventListener("dblclick", this.onStartBtnDblClick);
    this.endBtn.removeEventListener("dblclick", this.onRightBtnDblClick);

    // Overflow and underflow are moz specific events
    this.inner.removeEventListener("underflow", this.onUnderflow);
    this.inner.removeEventListener("overflow", this.onOverflow);
  }
}

/**
 * Display the ancestors of the current node and its children.
 * Only one "branch" of children are displayed (only one line).
 *
 * Mechanism:
 * - If no nodes displayed yet:
 *   then display the ancestor of the selected node and the selected node;
 *   else select the node;
 * - If the selected node is the last node displayed, append its first (if any).
 */
class HTMLBreadcrumbs {
  /**
   * @param {InspectorPanel} inspector The inspector hosting this widget.
   */
  constructor(inspector) {
    this.inspector = inspector;
    this.selection = this.inspector.selection;
    this.win = this.inspector.panelWin;
    this.doc = this.inspector.panelDoc;
    this._init();
  }
  get walker() {
    return this.inspector.walker;
  }

  _init() {
    this.outer = this.doc.getElementById("inspector-breadcrumbs");
    this.arrowScrollBox = new ArrowScrollBox(this.win, this.outer);

    this.container = this.arrowScrollBox.inner;
    this.scroll = this.scroll.bind(this);
    this.arrowScrollBox.on("overflow", this.scroll);

    this.outer.addEventListener("click", this, true);
    this.outer.addEventListener("mouseover", this, true);
    this.outer.addEventListener("mouseout", this, true);
    this.outer.addEventListener("focus", this, true);

    this.handleShortcut = this.handleShortcut.bind(this);

    this.outer.addEventListener(
      "focus",
      () => {
        this.initKeyShortcuts();
      },
      { once: true }
    );

    // We will save a list of already displayed nodes in this array.
    this.nodeHierarchy = [];

    // Last selected node in nodeHierarchy.
    this.currentIndex = -1;

    // Used to build a unique breadcrumb button Id.
    this.breadcrumbsWidgetItemId = 0;

    this.update = this.update.bind(this);
    this.updateWithMutations = this.updateWithMutations.bind(this);
    this.updateSelectors = this.updateSelectors.bind(this);
    this.selection.on("new-node-front", this.update);
    this.selection.on("pseudoclass", this.updateSelectors);
    this.selection.on("attribute-changed", this.updateSelectors);
    this.inspector.on("markupmutation", this.updateWithMutations);
    this.update();
  }

  initKeyShortcuts() {
    this.shortcuts = new KeyShortcuts({ window: this.win, target: this.outer });
    this.shortcuts.on("Right", this.handleShortcut);
    this.shortcuts.on("Left", this.handleShortcut);
  }

  /**
   * Build a string that represents the node: tagName#id.class1.class2.
   *
   * @param {NodeFront} nodeFront The node to pretty-print
   * @return {string}
   */
  prettyPrintNodeAsText(nodeFront) {
    let text = nodeFront.isShadowRoot
      ? SHADOW_ROOT_TAGNAME
      : nodeFront.displayName;

    if (nodeFront.id) {
      text += "#" + nodeFront.id;
    }

    if (nodeFront.className) {
      const classList = nodeFront.className.split(/\s+/);
      for (let i = 0; i < classList.length; i++) {
        text += "." + classList[i];
      }
    }

    for (const pseudo of nodeFront.pseudoClassLocks) {
      text += pseudo;
    }

    return text;
  }

  /**
   * Build <span>s that represent the node:
   *
   * ```html
   * <span class="breadcrumbs-widget-item-tag">tagName</span>
   * <span class="breadcrumbs-widget-item-id">#id</span>
   * <span class="breadcrumbs-widget-item-classes">.class1.class2</span>
   * ```
   *
   * @param {NodeFront} node The node to pretty-print
   * @returns {DocumentFragment}
   */
  prettyPrintNodeAsXHTML(node) {
    const tagLabel = this.doc.createElementNS(NS_XHTML, "span");
    tagLabel.className = "breadcrumbs-widget-item-tag";

    const idLabel = this.doc.createElementNS(NS_XHTML, "span");
    idLabel.className = "breadcrumbs-widget-item-id";

    const classesLabel = this.doc.createElementNS(NS_XHTML, "span");
    classesLabel.className = "breadcrumbs-widget-item-classes";

    const pseudosLabel = this.doc.createElementNS(NS_XHTML, "span");
    pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes";

    let tagText = node.isShadowRoot ? SHADOW_ROOT_TAGNAME : node.displayName;
    let idText = node.id ? "#" + node.id : "";
    let classesText = "";

    if (node.className) {
      const classList = node.className.split(/\s+/);
      for (let i = 0; i < classList.length; i++) {
        classesText += "." + classList[i];
      }
    }

    // Figure out which element (if any) needs ellipsing.
    // Substring for that element, then clear out any extras
    // (except for pseudo elements).
    const maxTagLength = MAX_LABEL_LENGTH;
    const maxIdLength = MAX_LABEL_LENGTH - tagText.length;
    const maxClassLength = MAX_LABEL_LENGTH - tagText.length - idText.length;

    if (tagText.length > maxTagLength) {
      tagText = tagText.substr(0, maxTagLength) + ELLIPSIS;
      idText = classesText = "";
    } else if (idText.length > maxIdLength) {
      idText = idText.substr(0, maxIdLength) + ELLIPSIS;
      classesText = "";
    } else if (classesText.length > maxClassLength) {
      classesText = classesText.substr(0, maxClassLength) + ELLIPSIS;
    }

    tagLabel.textContent = tagText;
    idLabel.textContent = idText;
    classesLabel.textContent = classesText;
    pseudosLabel.textContent = node.pseudoClassLocks.join("");

    const fragment = this.doc.createDocumentFragment();
    fragment.appendChild(tagLabel);
    fragment.appendChild(idLabel);
    fragment.appendChild(classesLabel);
    fragment.appendChild(pseudosLabel);

    return fragment;
  }

  /**
   * Generic event handler.
   *
   * @param {DOMEvent} event.
   */
  handleEvent(event) {
    if (event.type == "click" && event.button == 0) {
      this.handleClick(event);
    } else if (event.type == "mouseover") {
      this.handleMouseOver(event);
    } else if (event.type == "mouseout") {
      this.handleMouseOut(event);
    } else if (event.type == "focus") {
      this.handleFocus(event);
    }
  }

  /**
   * Focus event handler. When breadcrumbs container gets focus,
   * aria-activedescendant needs to be updated to currently selected
   * breadcrumb. Ensures that the focus stays on the container at all times.
   */
  handleFocus(event) {
    const node = this.nodeHierarchy[this.currentIndex];
    if (node) {
      this.outer.setAttribute("aria-activedescendant", node.button.id);
    } else {
      this.outer.removeAttribute("aria-activedescendant");
    }

    // The focus should always be set on the outer element
    if (event.target !== this.outer) {
      this.outer.focus();
    }
  }

  /**
   * On click navigate to the correct node.
   *
   * @param {DOMEvent} event.
   */
  handleClick(event) {
    const target = event.originalTarget;
    if (target.tagName == "button") {
      target.onBreadcrumbsClick();
    }
  }

  /**
   * On mouse over, highlight the corresponding content DOM Node.
   *
   * @param {DOMEvent} event.
   */
  handleMouseOver(event) {
    const target = event.originalTarget;
    if (target.tagName == "button") {
      target.onBreadcrumbsHover();
    }
  }

  /**
   * On mouse out, make sure to unhighlight.
   */
  handleMouseOut() {
    this.inspector.highlighters.hideHighlighterType(
      this.inspector.highlighters.TYPES.BOXMODEL
    );
  }

  /**
   * Handle a keyboard shortcut supported by the breadcrumbs widget.
   *
   * @param {string} name
   *        Name of the keyboard shortcut received.
   * @param {DOMEvent} event
   *        Original event that triggered the shortcut.
   */
  handleShortcut(event) {
    if (!this.selection.isElementNode()) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.keyPromise = (this.keyPromise || Promise.resolve(null)).then(() => {
      let currentnode;

      const isLeft = event.code === "ArrowLeft";
      const isRight = event.code === "ArrowRight";

      if (isLeft && this.currentIndex != 0) {
        currentnode = this.nodeHierarchy[this.currentIndex - 1];
      } else if (isRight && this.currentIndex < this.nodeHierarchy.length - 1) {
        currentnode = this.nodeHierarchy[this.currentIndex + 1];
      } else {
        return null;
      }

      this.outer.setAttribute("aria-activedescendant", currentnode.button.id);
      return this.selection.setNodeFront(currentnode.node, {
        reason: "breadcrumbs",
      });
    });
  }

  /**
   * Remove nodes and clean up.
   */
  destroy() {
    this.selection.off("new-node-front", this.update);
    this.selection.off("pseudoclass", this.updateSelectors);
    this.selection.off("attribute-changed", this.updateSelectors);
    this.inspector.off("markupmutation", this.updateWithMutations);

    this.container.removeEventListener("click", this, true);
    this.container.removeEventListener("mouseover", this, true);
    this.container.removeEventListener("mouseout", this, true);
    this.container.removeEventListener("focus", this, true);

    if (this.shortcuts) {
      this.shortcuts.destroy();
    }

    this.empty();

    this.arrowScrollBox.off("overflow", this.scroll);
    this.arrowScrollBox.destroy();
    this.arrowScrollBox = null;
    this.outer = null;
    this.container = null;
    this.nodeHierarchy = null;

    this.isDestroyed = true;
  }

  /**
   * Empty the breadcrumbs container.
   */
  empty() {
    this.container.replaceChildren();
  }

  /**
   * Set which button represent the selected node.
   *
   * @param {number} index Index of the displayed-button to select.
   */
  setCursor(index) {
    // Unselect the previously selected button
    if (
      this.currentIndex > -1 &&
      this.currentIndex < this.nodeHierarchy.length
    ) {
      this.nodeHierarchy[this.currentIndex].button.setAttribute(
        "aria-pressed",
        "false"
      );
    }
    if (index > -1) {
      this.nodeHierarchy[index].button.setAttribute("aria-pressed", "true");
    } else {
      // Unset active active descendant when all buttons are unselected.
      this.outer.removeAttribute("aria-activedescendant");
    }
    this.currentIndex = index;
  }

  /**
   * Get the index of the node in the cache.
   *
   * @param {NodeFront} node.
   * @returns {number} The index for this node or -1 if not found.
   */
  indexOf(node) {
    for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
      if (this.nodeHierarchy[i].node === node) {
        return i;
      }
    }
    return -1;
  }

  /**
   * Remove all the buttons and their references in the cache after a given
   * index.
   *
   * @param {number} index.
   */
  cutAfter(index) {
    while (this.nodeHierarchy.length > index + 1) {
      const toRemove = this.nodeHierarchy.pop();
      this.container.removeChild(toRemove.button);
    }
  }

  /**
   * Build a button representing the node.
   *
   * @param {NodeFront} node The node from the page.
   * @return {DOMNode} The <button> for this node.
   */
  buildButton(node) {
    const button = this.doc.createElementNS(NS_XHTML, "button");
    button.appendChild(this.prettyPrintNodeAsXHTML(node));
    button.className = "breadcrumbs-widget-item";
    button.id = "breadcrumbs-widget-item-" + this.breadcrumbsWidgetItemId++;

    button.setAttribute("tabindex", "-1");
    button.setAttribute("title", this.prettyPrintNodeAsText(node));

    button.onclick = () => {
      button.focus();
    };

    button.onBreadcrumbsClick = () => {
      this.selection.setNodeFront(node, { reason: "breadcrumbs" });
    };

    button.onBreadcrumbsHover = () => {
      this.inspector.highlighters.showHighlighterTypeForNode(
        this.inspector.highlighters.TYPES.BOXMODEL,
        node
      );
    };

    return button;
  }

  /**
   * Connecting the end of the breadcrumbs to a node.
   *
   * @param {NodeFront} node The node to reach.
   */
  expand(node) {
    const fragment = this.doc.createDocumentFragment();
    let lastButtonInserted = null;
    const originalLength = this.nodeHierarchy.length;
    let stopNode = null;
    if (originalLength > 0) {
      stopNode = this.nodeHierarchy[originalLength - 1].node;
    }
    while (node && node != stopNode) {
      if (node.tagName || node.isShadowRoot) {
        const button = this.buildButton(node);
        fragment.insertBefore(button, lastButtonInserted);
        lastButtonInserted = button;
        this.nodeHierarchy.splice(originalLength, 0, {
          node,
          button,
          currentPrettyPrintText: this.prettyPrintNodeAsText(node),
        });
      }
      node = node.parentOrHost();
    }
    this.container.appendChild(fragment, this.container.firstChild);
  }

  /**
   * Find the "youngest" ancestor of a node which is already in the breadcrumbs.
   *
   * @param {NodeFront} node.
   * @return {number} Index of the ancestor in the cache, or -1 if not found.
   */
  getCommonAncestor(node) {
    while (node) {
      const idx = this.indexOf(node);
      if (idx > -1) {
        return idx;
      }
      node = node.parentNode();
    }
    return -1;
  }

  /**
   * Ensure the selected node is visible.
   */
  scroll() {
    // FIXME bug 684352: make sure its immediate neighbors are visible too.
    if (!this.isDestroyed) {
      const element = this.nodeHierarchy[this.currentIndex].button;
      this.arrowScrollBox.scrollToElement(element, "end");
    }
  }

  /**
   * Update all button outputs.
   */
  updateSelectors() {
    if (this.isDestroyed) {
      return;
    }

    for (let i = this.nodeHierarchy.length - 1; i >= 0; i--) {
      const { node, button, currentPrettyPrintText } = this.nodeHierarchy[i];

      // If the output of the node doesn't change, skip the update.
      const textOutput = this.prettyPrintNodeAsText(node);
      if (currentPrettyPrintText === textOutput) {
        continue;
      }

      // Otherwise, update the whole markup for the button.
      button.replaceChildren(this.prettyPrintNodeAsXHTML(node));
      button.setAttribute("title", textOutput);

      this.nodeHierarchy[i].currentPrettyPrintText = textOutput;
    }
  }

  /**
   * Given a list of mutation changes (passed by the markupmutation event),
   * decide whether or not they are "interesting" to the current state of the
   * breadcrumbs widget, i.e. at least one of them should cause part of the
   * widget to be updated.
   *
   * @param {Array} mutations The mutations array.
   * @return {boolean}
   */
  _hasInterestingMutations(mutations) {
    if (!mutations || !mutations.length) {
      return false;
    }

    for (const mutation of mutations) {
      if (this._isInterestingMutation(mutation)) {
        return true;
      }
    }

    return false;
  }

  /**
   * Check if the provided mutation (from a markupmutation event) is relevant
   * for the current breadcrumbs.
   *
   * @param {object} mutation The mutation to check.
   * @return {boolean} true if the mutation is relevant, false otherwise.
   */
  _isInterestingMutation(mutation) {
    const { type, added, removed, target, attributeName } = mutation;
    if (type === "childList") {
      // Only interested in childList mutations if the added or removed
      // nodes are currently displayed.
      return (
        added.some(node => this.indexOf(node) > -1) ||
        removed.some(node => this.indexOf(node) > -1)
      );
    } else if (type === "attributes" && this.indexOf(target) > -1) {
      // Only interested in attributes mutations if the target is
      // currently displayed, and the attribute is either id or class.
      return attributeName === "class" || attributeName === "id";
    }
    return false;
  }

  /**
   * Update the breadcrumbs display when a new node is selected and there are
   * mutations.
   *
   * @param {Array} mutations An array of mutations in case this was called as
   * the "markupmutation" event listener.
   */
  updateWithMutations(mutations) {
    return this.update("markupmutation", mutations);
  }

  /**
   * Update the breadcrumbs display when a new node is selected.
   *
   * @param {string} reason The reason for the update, if any.
   * @param {Array} mutations An array of mutations in case this was called as
   * the "markupmutation" event listener.
   */
  update(reason, mutations) {
    if (this.isDestroyed) {
      return;
    }

    const hasInterestingMutations = this._hasInterestingMutations(mutations);
    if (reason === "markupmutation" && !hasInterestingMutations) {
      return;
    }

    if (!this.selection.isConnected()) {
      // remove all the crumbs
      this.cutAfter(-1);
      return;
    }

    // If this was an interesting deletion; then trim the breadcrumb trail
    let trimmed = false;
    if (reason === "markupmutation") {
      for (const { type, removed } of mutations) {
        if (type !== "childList") {
          continue;
        }

        for (const node of removed) {
          const removedIndex = this.indexOf(node);
          if (removedIndex > -1) {
            this.cutAfter(removedIndex - 1);
            trimmed = true;
          }
        }
      }
    }

    if (!this.selection.isElementNode() && !this.selection.isShadowRootNode()) {
      // no selection
      this.setCursor(-1);
      if (trimmed) {
        // Since something changed, notify the interested parties.
        this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
      }
      return;
    }

    let idx = this.indexOf(this.selection.nodeFront);

    // Is the node already displayed in the breadcrumbs?
    // (and there are no mutations that need re-display of the crumbs)
    if (idx > -1 && !hasInterestingMutations) {
      // Yes. We select it.
      this.setCursor(idx);
    } else {
      // No. Is the breadcrumbs display empty?
      if (this.nodeHierarchy.length) {
        // No. We drop all the element that are not direct ancestors
        // of the selection
        const parent = this.selection.nodeFront.parentNode();
        const ancestorIdx = this.getCommonAncestor(parent);
        this.cutAfter(ancestorIdx);
      }
      // we append the missing button between the end of the breadcrumbs display
      // and the current node.
      this.expand(this.selection.nodeFront);

      // we select the current node button
      idx = this.indexOf(this.selection.nodeFront);
      this.setCursor(idx);
    }

    const doneUpdating = this.inspector.updating("breadcrumbs");

    this.updateSelectors();

    // Make sure the selected node and its neighbours are visible.
    setTimeout(() => {
      try {
        this.scroll();
        this.inspector.emit("breadcrumbs-updated", this.selection.nodeFront);
        doneUpdating();
      } catch (e) {
        // Only log this as an error if we haven't been destroyed in the meantime.
        if (!this.isDestroyed) {
          console.error(e);
        }
      }
    }, 0);
  }
}

exports.HTMLBreadcrumbs = HTMLBreadcrumbs;
