tor-browser

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

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;