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;