diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/inspector/breadcrumbs.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/breadcrumbs.js')
-rw-r--r-- | devtools/client/inspector/breadcrumbs.js | 973 |
1 files changed, 973 insertions, 0 deletions
diff --git a/devtools/client/inspector/breadcrumbs.js b/devtools/client/inspector/breadcrumbs.js new file mode 100644 index 0000000000..68bafb591c --- /dev/null +++ b/devtools/client/inspector/breadcrumbs.js @@ -0,0 +1,973 @@ +/* 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 flags = require("resource://devtools/shared/flags.js"); +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 + * + * @param {Window} win The window containing the breadcrumbs + * @parem {DOMNode} container The element in which to put the scroll box + */ +function ArrowScrollBox(win, container) { + this.win = win; + this.doc = win.document; + this.container = container; + EventEmitter.decorate(this); + this.init(); +} + +ArrowScrollBox.prototype = { + // 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 {DOMNode} parent the parent node to which it should be appended + * @return {DOMNode} 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). + * + * @param {InspectorPanel} inspector The inspector hosting this widget. + */ +function HTMLBreadcrumbs(inspector) { + this.inspector = inspector; + this.selection = this.inspector.selection; + this.win = this.inspector.panelWin; + this.doc = this.inspector.panelDoc; + this._init(); +} + +exports.HTMLBreadcrumbs = HTMLBreadcrumbs; + +HTMLBreadcrumbs.prototype = { + 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); + + if (flags.testing) { + // In tests, we start listening immediately to avoid having to simulate a focus. + this.initKeyShortcuts(); + } else { + 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} node The node to pretty-print + * @return {String} + */ + prettyPrintNodeAsText(node) { + let text = node.isShadowRoot ? SHADOW_ROOT_TAGNAME : node.displayName; + if (node.isMarkerPseudoElement) { + text = "::marker"; + } else if (node.isBeforePseudoElement) { + text = "::before"; + } else if (node.isAfterPseudoElement) { + text = "::after"; + } + + if (node.id) { + text += "#" + node.id; + } + + if (node.className) { + const classList = node.className.split(/\s+/); + for (let i = 0; i < classList.length; i++) { + text += "." + classList[i]; + } + } + + for (const pseudo of node.pseudoClassLocks) { + text += pseudo; + } + + return text; + }, + + /** + * Build <span>s that represent the node: + * <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 plain"; + + const idLabel = this.doc.createElementNS(NS_XHTML, "span"); + idLabel.className = "breadcrumbs-widget-item-id plain"; + + const classesLabel = this.doc.createElementNS(NS_XHTML, "span"); + classesLabel.className = "breadcrumbs-widget-item-classes plain"; + + const pseudosLabel = this.doc.createElementNS(NS_XHTML, "span"); + pseudosLabel.className = "breadcrumbs-widget-item-pseudo-classes plain"; + + let tagText = node.isShadowRoot ? SHADOW_ROOT_TAGNAME : node.displayName; + if (node.isMarkerPseudoElement) { + tagText = "::marker"; + } else if (node.isBeforePseudoElement) { + tagText = "::before"; + } else if (node.isAfterPseudoElement) { + tagText = "::after"; + } + 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. + * @param {DOMEvent} event. + */ + handleFocus(event) { + event.stopPropagation(); + + const node = this.nodeHierarchy[this.currentIndex]; + if (node) { + this.outer.setAttribute("aria-activedescendant", node.button.id); + } else { + this.outer.removeAttribute("aria-activedescendant"); + } + + 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. + * @param {DOMEvent} event. + */ + handleMouseOut(event) { + 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() { + while (this.container.hasChildNodes()) { + this.container.firstChild.remove(); + } + }, + + /** + * 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.removeAttribute("checked"); + } + if (index > -1) { + this.nodeHierarchy[index].button.setAttribute("checked", "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. + while (button.hasChildNodes()) { + button.firstChild.remove(); + } + button.appendChild(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 { type, added, removed, target, attributeName } of mutations) { + 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"; + } + } + + // Catch all return in case the mutations array was empty, or in case none + // of the changes iterated above were interesting. + 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); + }, +}; |