markup-container.js (25956B)
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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); 8 const { 9 flashElementOn, 10 flashElementOff, 11 } = require("resource://devtools/client/inspector/markup/utils.js"); 12 13 const lazy = {}; 14 ChromeUtils.defineESModuleGetters(lazy, { 15 wrapMoveFocus: "resource://devtools/client/shared/focus.mjs", 16 }); 17 18 const DRAG_DROP_MIN_INITIAL_DISTANCE = 10; 19 const TYPES = { 20 TEXT_CONTAINER: "textcontainer", 21 ELEMENT_CONTAINER: "elementcontainer", 22 READ_ONLY_CONTAINER: "readonlycontainer", 23 }; 24 25 /** 26 * Unique identifier used to set markup container node id. 27 * 28 * @type {number} 29 */ 30 let markupContainerID = 0; 31 32 /** 33 * The main structure for storing a document node in the markup 34 * tree. Manages creation of the editor for the node and 35 * a <ul> for placing child elements, and expansion/collapsing 36 * of the element. 37 * 38 * This should not be instantiated directly, instead use one of: 39 * MarkupReadOnlyContainer 40 * MarkupTextContainer 41 * MarkupElementContainer 42 */ 43 class MarkupContainer { 44 // Get the UndoStack from the MarkupView. 45 get undo() { 46 // undo is a lazy getter in the MarkupView. 47 return this.markup.undo; 48 } 49 50 /** 51 * Initialize the MarkupContainer. Should be called while one 52 * of the other contain classes is instantiated. 53 * 54 * @param {MarkupView} markupView 55 * The markup view that owns this container. 56 * @param {NodeFront} node 57 * The node to display. 58 * @param {string} type 59 * The type of container to build. One of TYPES.TEXT_CONTAINER, 60 * TYPES.ELEMENT_CONTAINER, TYPES.READ_ONLY_CONTAINER 61 */ 62 initialize(markupView, node, type) { 63 this.markup = markupView; 64 this.node = node; 65 this.type = type; 66 this.win = this.markup._frame.contentWindow; 67 this.id = "treeitem-" + markupContainerID++; 68 this.htmlElt = this.win.document.documentElement; 69 70 this.buildMarkup(); 71 72 this.elt.container = this; 73 74 this._onMouseDown = this._onMouseDown.bind(this); 75 this._onClick = this._onClick.bind(this); 76 this._onToggle = this._onToggle.bind(this); 77 this._onKeyDown = this._onKeyDown.bind(this); 78 this._eventListenersAbortController = new this.win.AbortController(); 79 80 // Binding event listeners 81 const eventConfig = { signal: this._eventListenersAbortController.signal }; 82 this.elt.addEventListener("mousedown", this._onMouseDown, eventConfig); 83 this.elt.addEventListener("click", this._onClick, eventConfig); 84 this.elt.addEventListener("dblclick", this._onToggle, eventConfig); 85 if (this.expander) { 86 this.expander.addEventListener("click", this._onToggle, eventConfig); 87 } 88 89 // Marking the node as shown or hidden 90 this.updateIsDisplayed(); 91 92 if (node.isShadowRoot) { 93 Glean.devtoolsShadowdom.shadowRootDisplayed.set(true); 94 } 95 } 96 97 buildMarkup() { 98 this.elt = this.win.document.createElement("li"); 99 this.elt.classList.add("child", "collapsed"); 100 this.elt.setAttribute("role", "presentation"); 101 102 this.tagLine = this.win.document.createElement("div"); 103 this.tagLine.setAttribute("id", this.id); 104 this.tagLine.classList.add("tag-line"); 105 this.tagLine.setAttribute("role", "treeitem"); 106 this.tagLine.setAttribute("aria-level", this.level); 107 this.tagLine.setAttribute("aria-grabbed", this.isDragging); 108 this.elt.appendChild(this.tagLine); 109 110 this.mutationMarker = this.win.document.createElement("div"); 111 this.mutationMarker.classList.add("markup-tag-mutation-marker"); 112 this.mutationMarker.style.setProperty("--markup-level", this.level); 113 this.tagLine.appendChild(this.mutationMarker); 114 115 this.tagState = this.win.document.createElement("span"); 116 this.tagState.classList.add("tag-state"); 117 this.tagState.setAttribute("role", "presentation"); 118 this.tagLine.appendChild(this.tagState); 119 120 if (this.type !== TYPES.TEXT_CONTAINER) { 121 this.expander = this.win.document.createElement("span"); 122 this.expander.classList.add("theme-twisty", "expander"); 123 this.expander.setAttribute("role", "presentation"); 124 this.tagLine.appendChild(this.expander); 125 } 126 127 this.children = this.win.document.createElement("ul"); 128 this.children.classList.add("children"); 129 this.children.setAttribute("role", "group"); 130 this.elt.appendChild(this.children); 131 } 132 133 toString() { 134 return "[MarkupContainer for " + this.node + "]"; 135 } 136 137 isPreviewable() { 138 if (this.node.tagName && !this.node.isPseudoElement) { 139 const tagName = this.node.tagName.toLowerCase(); 140 const srcAttr = this.editor.getAttributeElement("src"); 141 const isImage = tagName === "img" && srcAttr; 142 const isCanvas = tagName === "canvas"; 143 144 return isImage || isCanvas; 145 } 146 147 return false; 148 } 149 150 /** 151 * Show whether the element is displayed or not 152 * If an element has the attribute `display: none` or has been hidden with 153 * the H key, it is not displayed (faded in markup view). 154 * Otherwise, it is displayed. 155 */ 156 updateIsDisplayed() { 157 this.elt.classList.remove("not-displayed"); 158 if (!this.node.isDisplayed || this.node.hidden) { 159 this.elt.classList.add("not-displayed"); 160 } 161 } 162 163 /** 164 * True if the current node has children. The MarkupView 165 * will set this attribute for the MarkupContainer. 166 */ 167 _hasChildren = false; 168 169 get hasChildren() { 170 return this._hasChildren; 171 } 172 173 set hasChildren(value) { 174 this._hasChildren = value; 175 this.updateExpander(); 176 } 177 178 /** 179 * A list of all elements with tabindex that are not in container's children. 180 */ 181 get focusableElms() { 182 return [...this.tagLine.querySelectorAll("[tabindex]")]; 183 } 184 185 /** 186 * An indicator that the container internals are focusable. 187 */ 188 get canFocus() { 189 return this._canFocus; 190 } 191 192 /** 193 * Toggle focusable state for container internals. 194 */ 195 set canFocus(value) { 196 if (this._canFocus === value) { 197 return; 198 } 199 200 this._canFocus = value; 201 202 if (value) { 203 this.tagLine.addEventListener("keydown", this._onKeyDown, true); 204 this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "0")); 205 } else { 206 this.tagLine.removeEventListener("keydown", this._onKeyDown, true); 207 // Exclude from tab order. 208 this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "-1")); 209 } 210 } 211 212 /** 213 * If conatiner and its contents are focusable, exclude them from tab order, 214 * and, if necessary, remove focus. 215 */ 216 clearFocus() { 217 if (!this.canFocus) { 218 return; 219 } 220 221 this.canFocus = false; 222 const doc = this.markup.doc; 223 224 if (!doc.activeElement || doc.activeElement === doc.body) { 225 return; 226 } 227 228 let parent = doc.activeElement; 229 230 while (parent && parent !== this.elt) { 231 parent = parent.parentNode; 232 } 233 234 if (parent) { 235 doc.activeElement.blur(); 236 } 237 } 238 239 /** 240 * True if the current node can be expanded. 241 */ 242 get canExpand() { 243 return this._hasChildren && !this.node.inlineTextChild; 244 } 245 246 /** 247 * True if this is the root <html> element and can't be collapsed. 248 */ 249 get mustExpand() { 250 return this.node._parent === this.markup.walker.rootNode; 251 } 252 253 /** 254 * True if current node can be expanded and collapsed. 255 */ 256 get showExpander() { 257 return this.canExpand && !this.mustExpand; 258 } 259 260 updateExpander() { 261 if (!this.expander) { 262 return; 263 } 264 265 if (this.showExpander) { 266 this.elt.classList.add("expandable"); 267 this.expander.style.visibility = "visible"; 268 // Update accessibility expanded state. 269 this.tagLine.setAttribute("aria-expanded", this.expanded); 270 } else { 271 this.elt.classList.remove("expandable"); 272 this.expander.style.visibility = "hidden"; 273 // No need for accessible expanded state indicator when expander is not 274 // shown. 275 this.tagLine.removeAttribute("aria-expanded"); 276 } 277 } 278 279 /** 280 * If current node has no children, ignore them. Otherwise, consider them a 281 * group from the accessibility point of view. 282 */ 283 setChildrenRole() { 284 this.children.setAttribute( 285 "role", 286 this.hasChildren ? "group" : "presentation" 287 ); 288 } 289 290 /** 291 * Set an appropriate DOM tree depth level for a node and its subtree. 292 */ 293 updateLevel() { 294 // ARIA level should already be set when the container markup is created. 295 const currentLevel = this.tagLine.getAttribute("aria-level"); 296 const newLevel = this.level; 297 if (currentLevel === newLevel) { 298 // If level did not change, ignore this node and its subtree. 299 return; 300 } 301 302 this.tagLine.setAttribute("aria-level", newLevel); 303 const childContainers = this.getChildContainers(); 304 if (childContainers) { 305 childContainers.forEach(container => container.updateLevel()); 306 } 307 } 308 309 /** 310 * If the node has children, return the list of containers for all these 311 * children. 312 */ 313 getChildContainers() { 314 if (!this.hasChildren) { 315 return null; 316 } 317 318 return [...this.children.children] 319 .filter(node => node.container) 320 .map(node => node.container); 321 } 322 323 /** 324 * True if the node has been visually expanded in the tree. 325 */ 326 get expanded() { 327 return !this.elt.classList.contains("collapsed"); 328 } 329 330 setExpanded(value) { 331 if (!this.expander) { 332 return; 333 } 334 335 if (!this.canExpand) { 336 value = false; 337 } 338 339 if (this.mustExpand) { 340 value = true; 341 } 342 343 if (value && this.elt.classList.contains("collapsed")) { 344 this.showCloseTagLine(); 345 346 this.elt.classList.remove("collapsed"); 347 this.expander.setAttribute("open", ""); 348 this.hovered = false; 349 this.markup.emit("expanded"); 350 } else if (!value) { 351 this.hideCloseTagLine(); 352 353 this.elt.classList.add("collapsed"); 354 this.expander.removeAttribute("open"); 355 this.markup.emit("collapsed"); 356 } 357 358 if (this.showExpander) { 359 this.tagLine.setAttribute("aria-expanded", this.expanded); 360 } 361 362 if (this.node.isShadowRoot) { 363 Glean.devtoolsShadowdom.shadowRootExpanded.set(true); 364 } 365 } 366 367 /** 368 * Expanding a node means cloning its "inline" closing tag into a new 369 * tag-line that the user can interact with and showing the children. 370 */ 371 showCloseTagLine() { 372 // Only element containers display a closing tag line. #document has no closing line. 373 if (this.type !== TYPES.ELEMENT_CONTAINER) { 374 return; 375 } 376 377 // Retrieve the closest .close node for this container. 378 const closingTag = this.elt.querySelector(".close"); 379 if (!closingTag) { 380 return; 381 } 382 383 // Create the closing tag-line element if not already created. 384 if (!this.closeTagLine) { 385 const line = this.markup.doc.createElement("div"); 386 line.classList.add("tag-line"); 387 // Closing tag is not important for accessibility. 388 line.setAttribute("role", "presentation"); 389 390 const tagState = this.markup.doc.createElement("div"); 391 tagState.classList.add("tag-state"); 392 line.appendChild(tagState); 393 394 line.appendChild(closingTag.cloneNode(true)); 395 396 flashElementOff(line); 397 this.closeTagLine = line; 398 } 399 this.elt.appendChild(this.closeTagLine); 400 } 401 402 /** 403 * Hide the closing tag-line element which should only be displayed when the container 404 * is expanded. 405 */ 406 hideCloseTagLine() { 407 if (!this.closeTagLine) { 408 return; 409 } 410 411 this.elt.removeChild(this.closeTagLine); 412 this.closeTagLine = undefined; 413 } 414 415 parentContainer() { 416 return this.elt.parentNode ? this.elt.parentNode.container : null; 417 } 418 419 /** 420 * Determine tree depth level of a given node. This is used to specify ARIA 421 * level for node tree items and to give them better semantic context. 422 */ 423 get level() { 424 let level = 1; 425 let parent = this.node.parentNode(); 426 while (parent && parent !== this.markup.walker.rootNode) { 427 level++; 428 parent = parent.parentNode(); 429 } 430 return level; 431 } 432 433 _isDragging = false; 434 _dragStartY = 0; 435 436 set isDragging(isDragging) { 437 const rootElt = this.markup.getContainer(this.markup._rootNode).elt; 438 this._isDragging = isDragging; 439 this.markup.isDragging = isDragging; 440 this.tagLine.setAttribute("aria-grabbed", isDragging); 441 442 if (isDragging) { 443 this.htmlElt.classList.add("dragging"); 444 this.elt.classList.add("dragging"); 445 this.markup.doc.body.classList.add("dragging"); 446 rootElt.setAttribute("aria-dropeffect", "move"); 447 } else { 448 this.htmlElt.classList.remove("dragging"); 449 this.elt.classList.remove("dragging"); 450 this.markup.doc.body.classList.remove("dragging"); 451 rootElt.setAttribute("aria-dropeffect", "none"); 452 } 453 } 454 455 get isDragging() { 456 return this._isDragging; 457 } 458 459 /** 460 * Check if element is draggable. 461 */ 462 isDraggable() { 463 const tagName = this.node.tagName && this.node.tagName.toLowerCase(); 464 465 return ( 466 !this.node.isPseudoElement && 467 !this.node.isNativeAnonymous && 468 !this.node.isDocumentElement && 469 tagName !== "body" && 470 tagName !== "head" && 471 this.win.getSelection().isCollapsed && 472 this.node.parentNode() && 473 this.node.parentNode().tagName !== null 474 ); 475 } 476 477 isSlotted() { 478 return false; 479 } 480 481 _onKeyDown(event) { 482 const { target, keyCode, shiftKey } = event; 483 const isInput = this.markup.isInputOrTextareaOrInCodeMirrorEditor(target); 484 485 // Ignore all keystrokes that originated in editors except for when 'Tab' is 486 // pressed. 487 if (isInput && keyCode !== KeyCodes.DOM_VK_TAB) { 488 return; 489 } 490 491 switch (keyCode) { 492 case KeyCodes.DOM_VK_TAB: 493 // Only handle 'Tab' if tabbable element is on the edge (first or last). 494 if (isInput) { 495 // Corresponding tabbable element is editor's next sibling. 496 const next = lazy.wrapMoveFocus( 497 this.focusableElms, 498 target.nextSibling, 499 shiftKey 500 ); 501 if (next) { 502 event.preventDefault(); 503 // Keep the editing state if possible. 504 if (next._editable) { 505 const e = this.markup.doc.createEvent("Event"); 506 e.initEvent(next._trigger, true, true); 507 next.dispatchEvent(e); 508 } 509 } 510 } else { 511 const next = lazy.wrapMoveFocus(this.focusableElms, target, shiftKey); 512 if (next) { 513 event.preventDefault(); 514 } 515 } 516 break; 517 case KeyCodes.DOM_VK_ESCAPE: 518 this.clearFocus(); 519 this.markup.getContainer(this.markup._rootNode).elt.focus(); 520 if (this.isDragging) { 521 // Escape when dragging is handled by markup view itself. 522 return; 523 } 524 event.preventDefault(); 525 break; 526 default: 527 return; 528 } 529 event.stopPropagation(); 530 } 531 532 _onMouseDown(event) { 533 const { target, button, metaKey, ctrlKey } = event; 534 const isLeftClick = button === 0; 535 const isMiddleClick = button === 1; 536 const isMetaClick = isLeftClick && (metaKey || ctrlKey); 537 538 // The "show more nodes" button already has its onclick, so early return. 539 if (target.nodeName === "button") { 540 return; 541 } 542 543 // Bail out when clicking on arrow expanders to avoid selecting the row. 544 if (target.classList.contains("expander")) { 545 return; 546 } 547 548 // target is the MarkupContainer itself. 549 this.hovered = false; 550 this.markup.navigate(this); 551 // Make container tabbable descendants tabbable and focus in. 552 this.canFocus = true; 553 this.focus({ fromMouseEvent: true }); 554 event.stopPropagation(); 555 556 // Preventing the default behavior will avoid the body to gain focus on 557 // mouseup (through bubbling) when clicking on a non focusable node in the 558 // line. So, if the click happened outside of a focusable element, do 559 // prevent the default behavior, so that the tagname or textcontent gains 560 // focus. 561 if (!target.closest(".editor [tabindex]")) { 562 event.preventDefault(); 563 } 564 565 // Middle clicks will trigger the scroll lock feature to turn on. 566 // The toolbox is normally responsible for calling preventDefault when 567 // needed, but we prevent markup-view mousedown events from bubbling up (via 568 // stopPropagation). So we have to preventDefault here as well in order to 569 // avoid this issue. 570 if (isMiddleClick) { 571 event.preventDefault(); 572 } 573 574 // Follow attribute links if middle or meta click. 575 if (isMiddleClick || isMetaClick) { 576 this._openAttributeLink(target.dataset.type, target.dataset.link); 577 return; 578 } 579 580 // Start node drag & drop (if the mouse moved, see _onMouseMove). 581 if (isLeftClick && this.isDraggable()) { 582 this._isPreDragging = true; 583 this._dragStartY = event.pageY; 584 this.markup._draggedContainer = this; 585 } 586 } 587 588 _onClick(event) { 589 const { target } = event; 590 if (target.nodeName !== "button") { 591 return; 592 } 593 594 // We only care about handling click/keyboard activation for buttons inside 595 // "link" attributes (e.g. the "select node" button) 596 const closestLinkEl = target.closest("[data-link]"); 597 if (!closestLinkEl) { 598 return; 599 } 600 601 this._openAttributeLink( 602 closestLinkEl.dataset.type, 603 closestLinkEl.dataset.link 604 ); 605 event.stopPropagation(); 606 } 607 608 /** 609 * Open a "link" found in a node's attribute in the markup-view 610 * 611 * @param {string} type: A node-attribute-parser.js ATTRIBUTE_TYPES 612 * @param {string} link: A "link" as returned by the `parseAttribute` function from 613 * node-attribute-parser.js . This can be an actual URL, but could be 614 * something else (e.g. an element id). 615 */ 616 _openAttributeLink(type, link) { 617 // Make container tabbable descendants not tabbable (by default). 618 this.canFocus = false; 619 this.markup.followAttributeLink(type, link); 620 } 621 622 /** 623 * On mouse up, stop dragging. 624 * This handler is called from the markup view, to reduce number of listeners. 625 */ 626 async onMouseUp() { 627 this._isPreDragging = false; 628 this.markup._draggedContainer = null; 629 630 if (this.isDragging) { 631 this.cancelDragging(); 632 633 if (!this.markup.dropTargetNodes) { 634 return; 635 } 636 637 const { nextSibling, parent } = this.markup.dropTargetNodes; 638 const { walkerFront } = parent; 639 await walkerFront.insertBefore(this.node, parent, nextSibling); 640 this.markup.emit("drop-completed"); 641 } 642 } 643 644 /** 645 * On mouse move, move the dragged element and indicate the drop target. 646 * This handler is called from the markup view, to reduce number of listeners. 647 */ 648 onMouseMove(event) { 649 // If this is the first move after mousedown, only start dragging after the 650 // mouse has travelled a few pixels and then indicate the start position. 651 const initialDiff = Math.abs(event.pageY - this._dragStartY); 652 if (this._isPreDragging && initialDiff >= DRAG_DROP_MIN_INITIAL_DISTANCE) { 653 this._isPreDragging = false; 654 this.isDragging = true; 655 656 // If this is the last child, use the closing <div.tag-line> of parent as 657 // indicator. 658 const position = 659 this.elt.nextElementSibling || 660 this.markup.getContainer(this.node.parentNode()).closeTagLine; 661 this.markup.indicateDragTarget(position); 662 } 663 664 if (this.isDragging) { 665 const x = 0; 666 let y = event.pageY - this.win.scrollY; 667 668 // Ensure we keep the dragged element within the markup view. 669 if (y < 0) { 670 y = 0; 671 } else if (y >= this.markup.doc.body.offsetHeight - this.win.scrollY) { 672 y = this.markup.doc.body.offsetHeight - this.win.scrollY - 1; 673 } 674 675 const diff = y - this._dragStartY + this.win.scrollY; 676 this.elt.style.top = diff + "px"; 677 678 const el = this.markup.doc.elementFromPoint(x, y); 679 this.markup.indicateDropTarget(el); 680 } 681 } 682 683 cancelDragging() { 684 if (!this.isDragging) { 685 return; 686 } 687 688 this._isPreDragging = false; 689 this.isDragging = false; 690 this.elt.style.removeProperty("top"); 691 } 692 693 /** 694 * Temporarily flash the container to attract attention. 695 * Used for markup mutations. 696 */ 697 flashMutation() { 698 if (!this.selected) { 699 flashElementOn(this.tagState, { 700 foregroundElt: this.editor.elt, 701 backgroundClass: "theme-bg-contrast", 702 }); 703 if (this._flashMutationTimer) { 704 clearTimeout(this._flashMutationTimer); 705 this._flashMutationTimer = null; 706 } 707 this._flashMutationTimer = setTimeout(() => { 708 flashElementOff(this.tagState, { 709 foregroundElt: this.editor.elt, 710 backgroundClass: "theme-bg-contrast", 711 }); 712 }, this.markup.CONTAINER_FLASHING_DURATION); 713 } 714 } 715 716 _hovered = false; 717 718 /** 719 * Highlight the currently hovered tag + its closing tag if necessary 720 * (that is if the tag is expanded) 721 */ 722 set hovered(value) { 723 this.tagState.classList.remove("flash-out"); 724 this._hovered = value; 725 if (value) { 726 if (!this.selected) { 727 this.tagState.classList.add("tag-hover"); 728 } 729 if (this.closeTagLine) { 730 this.closeTagLine 731 .querySelector(".tag-state") 732 .classList.add("tag-hover"); 733 } 734 } else { 735 this.tagState.classList.remove("tag-hover"); 736 if (this.closeTagLine) { 737 this.closeTagLine 738 .querySelector(".tag-state") 739 .classList.remove("tag-hover"); 740 } 741 } 742 } 743 744 /** 745 * True if the container is visible in the markup tree. 746 */ 747 get visible() { 748 return this.elt.getBoundingClientRect().height > 0; 749 } 750 751 /** 752 * True if the container is currently selected. 753 */ 754 _selected = false; 755 756 get selected() { 757 return this._selected; 758 } 759 760 set selected(value) { 761 this.tagState.classList.remove("flash-out"); 762 this._selected = value; 763 this.editor.selected = value; 764 // Markup tree item should have accessible selected state. 765 this.tagLine.setAttribute("aria-selected", value); 766 if (this._selected) { 767 const container = this.markup.getContainer(this.markup._rootNode); 768 if (container) { 769 container.elt.setAttribute("aria-activedescendant", this.id); 770 } 771 this.tagLine.setAttribute("selected", ""); 772 this.tagState.classList.add("theme-selected"); 773 } else { 774 this.tagLine.removeAttribute("selected"); 775 this.tagState.classList.remove("theme-selected"); 776 } 777 } 778 779 /** 780 * Update the container's editor to the current state of the 781 * viewed node. 782 */ 783 update(mutationBreakpoints) { 784 if (this.node.pseudoClassLocks.length) { 785 this.elt.classList.add("pseudoclass-locked"); 786 } else { 787 this.elt.classList.remove("pseudoclass-locked"); 788 } 789 790 if (mutationBreakpoints) { 791 const allMutationsDisabled = Array.from( 792 mutationBreakpoints.values() 793 ).every(element => element === false); 794 795 if (mutationBreakpoints.size > 0) { 796 this.mutationMarker.classList.add("has-mutations"); 797 this.mutationMarker.classList.toggle( 798 "mutation-breakpoint-disabled", 799 allMutationsDisabled 800 ); 801 } else { 802 this.mutationMarker.classList.remove("has-mutations"); 803 } 804 } 805 806 this.updateIsDisplayed(); 807 808 if (this.editor.update) { 809 this.editor.update(); 810 } 811 } 812 813 /** 814 * Try to put keyboard focus on the current editor. 815 * 816 * @param {object} options 817 * @param {boolean} options.fromMouseEvent: Set to true if this is called from a mouse event. 818 */ 819 focus({ fromMouseEvent = false } = {}) { 820 // Elements with tabindex of -1 are not focusable. 821 const focusable = this.editor.elt.querySelector("[tabindex='0']"); 822 if (focusable) { 823 // When focus is coming from a mouse event: 824 // - prevent :focus-visible to be applied to the element 825 // - don't scroll element into view, as this could change the horizontal scroll, 826 // and the element is already visible since the user clicked on it. 827 focusable.focus({ 828 preventScroll: fromMouseEvent, 829 focusVisible: !fromMouseEvent, 830 }); 831 } 832 } 833 834 _onToggle(event) { 835 event.stopPropagation(); 836 837 // Prevent the html tree from expanding when an event bubble, display or scrollable 838 // node is clicked. 839 if ( 840 event.target.dataset.event || 841 event.target.dataset.display || 842 event.target.dataset.scrollable 843 ) { 844 return; 845 } 846 847 this.expandContainer(event.altKey); 848 } 849 850 /** 851 * Expands the markup container if it has children. 852 * 853 * @param {boolean} applyToDescendants 854 * Whether all descendants should also be expanded/collapsed 855 */ 856 expandContainer(applyToDescendants) { 857 if (this.hasChildren) { 858 this.markup.setNodeExpanded( 859 this.node, 860 !this.expanded, 861 applyToDescendants 862 ); 863 } 864 } 865 866 /** 867 * Get rid of event listeners and references, when the container is no longer 868 * needed 869 */ 870 destroy() { 871 // Remove event listeners 872 if (this._eventListenersAbortController) { 873 this._eventListenersAbortController.abort(); 874 } 875 this.tagLine.removeEventListener("keydown", this._onKeyDown, true); 876 877 if (this.markup._draggedContainer === this) { 878 this.markup._draggedContainer = null; 879 } 880 881 this.win = null; 882 this.htmlElt = null; 883 this._eventListenersAbortController = null; 884 885 // Recursively destroy children containers 886 let firstChild = this.children.firstChild; 887 while (firstChild) { 888 // Not all children of a container are containers themselves 889 // ("show more nodes" button is one example) 890 if (firstChild.container) { 891 firstChild.container.destroy(); 892 } 893 this.children.removeChild(firstChild); 894 firstChild = this.children.firstChild; 895 } 896 897 this.editor.destroy(); 898 } 899 } 900 901 module.exports = MarkupContainer;