tor-browser

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

element-editor.js (40609B)


      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 TextEditor = require("resource://devtools/client/inspector/markup/views/text-editor.js");
      8 const { truncateString } = require("resource://devtools/shared/string.js");
      9 const {
     10  editableField,
     11  InplaceEditor,
     12 } = require("resource://devtools/client/shared/inplace-editor.js");
     13 const {
     14  parseAttribute,
     15  ATTRIBUTE_TYPES,
     16 } = require("resource://devtools/client/shared/node-attribute-parser.js");
     17 
     18 loader.lazyRequireGetter(
     19  this,
     20  [
     21    "flashElementOn",
     22    "flashElementOff",
     23    "getAutocompleteMaxWidth",
     24    "parseAttributeValues",
     25  ],
     26  "resource://devtools/client/inspector/markup/utils.js",
     27  true
     28 );
     29 
     30 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
     31 const INSPECTOR_L10N = new LocalizationHelper(
     32  "devtools/client/locales/inspector.properties"
     33 );
     34 
     35 // Page size for pageup/pagedown
     36 const COLLAPSE_DATA_URL_REGEX = /^data.+base64/;
     37 const COLLAPSE_DATA_URL_LENGTH = 60;
     38 
     39 // Contains only void (without end tag) HTML elements
     40 const HTML_VOID_ELEMENTS = [
     41  "area",
     42  "base",
     43  "br",
     44  "col",
     45  "command",
     46  "embed",
     47  "hr",
     48  "img",
     49  "input",
     50  "keygen",
     51  "link",
     52  "meta",
     53  "param",
     54  "source",
     55  "track",
     56  "wbr",
     57 ];
     58 
     59 // Contains only valid computed display property types of the node to display in the
     60 // element markup and their respective title tooltip text.
     61 const DISPLAY_TYPES = {
     62  flex: INSPECTOR_L10N.getStr("markupView.display.flex.tooltiptext2"),
     63  "inline-flex": INSPECTOR_L10N.getStr(
     64    "markupView.display.inlineFlex.tooltiptext2"
     65  ),
     66  grid: INSPECTOR_L10N.getStr("markupView.display.grid.tooltiptext2"),
     67  "inline-grid": INSPECTOR_L10N.getStr(
     68    "markupView.display.inlineGrid.tooltiptext2"
     69  ),
     70  subgrid: INSPECTOR_L10N.getStr("markupView.display.subgrid.tooltiptiptext"),
     71  "flow-root": INSPECTOR_L10N.getStr("markupView.display.flowRoot.tooltiptext"),
     72  contents: INSPECTOR_L10N.getStr("markupView.display.contents.tooltiptext2"),
     73 };
     74 
     75 /**
     76 * Creates an editor for an Element node.
     77 */
     78 class ElementEditor {
     79  /**
     80   * @param  {MarkupContainer} container
     81   *         The container owning this editor.
     82   * @param  {NodeFront} node
     83   *         The NodeFront being edited.
     84   */
     85  constructor(container, node) {
     86    this.container = container;
     87    this.node = node;
     88    this.markup = this.container.markup;
     89    this.doc = this.markup.doc;
     90    this.inspector = this.markup.inspector;
     91    this.highlighters = this.markup.highlighters;
     92    this._cssProperties = this.inspector.cssProperties;
     93 
     94    this.isOverflowDebuggingEnabled = Services.prefs.getBoolPref(
     95      "devtools.overflow.debugging.enabled"
     96    );
     97 
     98    // If this is a scrollable element, this specifies whether or not its overflow causing
     99    // elements are highlighted. Otherwise, it is null if the element is not scrollable.
    100    this.highlightingOverflowCausingElements = this.node.isScrollable
    101      ? false
    102      : null;
    103 
    104    this.attrElements = new Map();
    105    this.animationTimers = {};
    106 
    107    this.elt = null;
    108    this.tag = null;
    109    this.closeTag = null;
    110    this.attrList = null;
    111    this.newAttr = null;
    112    this.closeElt = null;
    113 
    114    this.onCustomBadgeClick = this.onCustomBadgeClick.bind(this);
    115    this.onDisplayBadgeClick = this.onDisplayBadgeClick.bind(this);
    116    this.onScrollableBadgeClick = this.onScrollableBadgeClick.bind(this);
    117    this.onExpandBadgeClick = this.onExpandBadgeClick.bind(this);
    118    this.onTagEdit = this.onTagEdit.bind(this);
    119 
    120    this.buildMarkup();
    121 
    122    const isVoidElement = HTML_VOID_ELEMENTS.includes(this.node.displayName);
    123    if (node.isInHTMLDocument && isVoidElement) {
    124      this.elt.classList.add("void-element");
    125    }
    126 
    127    this.update();
    128    this.initialized = true;
    129  }
    130  buildMarkup() {
    131    this.elt = this.doc.createElement("span");
    132    this.elt.classList.add("editor");
    133 
    134    this.renderOpenTag();
    135    this.renderEventBadge();
    136    this.renderCloseTag();
    137 
    138    // Make the tag name editable (unless this is a remote node or
    139    // a document element)
    140    if (!this.node.isDocumentElement) {
    141      // Make the tag optionally tabbable but not by default.
    142      this.tag.setAttribute("tabindex", "-1");
    143      editableField({
    144        element: this.tag,
    145        multiline: true,
    146        maxWidth: () => getAutocompleteMaxWidth(this.tag, this.container.elt),
    147        trigger: "dblclick",
    148        stopOnReturn: true,
    149        done: this.onTagEdit,
    150        cssProperties: this._cssProperties,
    151      });
    152    }
    153  }
    154 
    155  renderOpenTag() {
    156    const open = this.doc.createElement("span");
    157    open.classList.add("open");
    158    open.appendChild(this.doc.createTextNode("<"));
    159    this.elt.appendChild(open);
    160 
    161    this.tag = this.doc.createElement("span");
    162    this.tag.classList.add("tag", "force-color-on-flash");
    163    this.tag.setAttribute("tabindex", "-1");
    164    this.tag.textContent = this.node.displayName;
    165    open.appendChild(this.tag);
    166 
    167    this.renderAttributes(open);
    168    this.renderNewAttributeEditor(open);
    169 
    170    const closingBracket = this.doc.createElement("span");
    171    closingBracket.classList.add("closing-bracket");
    172    closingBracket.textContent = ">";
    173    open.appendChild(closingBracket);
    174  }
    175 
    176  renderAttributes(containerEl) {
    177    this.attrList = this.doc.createElement("span");
    178    containerEl.appendChild(this.attrList);
    179  }
    180 
    181  renderNewAttributeEditor(containerEl) {
    182    this.newAttr = this.doc.createElement("span");
    183    this.newAttr.classList.add("newattr");
    184    this.newAttr.setAttribute("tabindex", "-1");
    185    this.newAttr.setAttribute(
    186      "aria-label",
    187      INSPECTOR_L10N.getStr("markupView.newAttribute.label")
    188    );
    189    containerEl.appendChild(this.newAttr);
    190 
    191    // Make the new attribute space editable.
    192    this.newAttr.editMode = editableField({
    193      element: this.newAttr,
    194      multiline: true,
    195      inputClass: "newattr-input",
    196      maxWidth: () => getAutocompleteMaxWidth(this.newAttr, this.container.elt),
    197      trigger: "dblclick",
    198      stopOnReturn: true,
    199      contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
    200      popup: this.markup.popup,
    201      done: (val, commit) => {
    202        if (!commit) {
    203          return;
    204        }
    205 
    206        const doMods = this._startModifyingAttributes();
    207        const undoMods = this._startModifyingAttributes();
    208        this._applyAttributes(val, null, doMods, undoMods);
    209        this.container.undo.do(
    210          () => {
    211            doMods.apply();
    212          },
    213          function () {
    214            undoMods.apply();
    215          }
    216        );
    217      },
    218      cssProperties: this._cssProperties,
    219    });
    220  }
    221 
    222  renderEventBadge() {
    223    this.expandBadge = this.doc.createElement("span");
    224    this.expandBadge.classList.add("markup-expand-badge");
    225    this.expandBadge.addEventListener("click", this.onExpandBadgeClick);
    226    this.elt.appendChild(this.expandBadge);
    227  }
    228 
    229  renderCloseTag() {
    230    const close = this.doc.createElement("span");
    231    close.classList.add("close");
    232    close.appendChild(this.doc.createTextNode("</"));
    233    this.elt.appendChild(close);
    234 
    235    this.closeTag = this.doc.createElement("span");
    236    this.closeTag.classList.add("tag", "force-color-on-flash");
    237    this.closeTag.textContent = this.node.displayName;
    238    close.appendChild(this.closeTag);
    239 
    240    close.appendChild(this.doc.createTextNode(">"));
    241  }
    242 
    243  get displayBadge() {
    244    return this._displayBadge;
    245  }
    246 
    247  set selected(value) {
    248    if (this.textEditor) {
    249      this.textEditor.selected = value;
    250    }
    251  }
    252 
    253  flashAttribute(attrName) {
    254    if (this.animationTimers[attrName]) {
    255      clearTimeout(this.animationTimers[attrName]);
    256    }
    257 
    258    flashElementOn(this.getAttributeElement(attrName), {
    259      backgroundClass: "theme-bg-contrast",
    260    });
    261 
    262    this.animationTimers[attrName] = setTimeout(() => {
    263      flashElementOff(this.getAttributeElement(attrName), {
    264        backgroundClass: "theme-bg-contrast",
    265      });
    266    }, this.markup.CONTAINER_FLASHING_DURATION);
    267  }
    268 
    269  /**
    270   * Returns information about node in the editor.
    271   *
    272   * @param  {DOMNode} node
    273   *         The node to get information from.
    274   * @return {object} An object literal with the following information:
    275   *         {type: "attribute", name: "rel", value: "index", el: node}
    276   */
    277  getInfoAtNode(node) {
    278    if (!node) {
    279      return null;
    280    }
    281 
    282    let type = null;
    283    let name = null;
    284    let value = null;
    285 
    286    // Attribute
    287    const attribute = node.closest(".attreditor");
    288    if (attribute) {
    289      type = "attribute";
    290      name = attribute.dataset.attr;
    291      value = attribute.dataset.value;
    292    }
    293 
    294    return { type, name, value, el: node };
    295  }
    296 
    297  /**
    298   * Update the state of the editor from the node.
    299   */
    300  update() {
    301    const nodeAttributes = this.node.attributes || [];
    302 
    303    // Keep the data model in sync with attributes on the node.
    304    const currentAttributes = new Set(nodeAttributes.map(a => a.name));
    305    for (const name of this.attrElements.keys()) {
    306      if (!currentAttributes.has(name)) {
    307        this.removeAttribute(name);
    308      }
    309    }
    310 
    311    // Only loop through the current attributes on the node.  Missing
    312    // attributes have already been removed at this point.
    313    for (const attr of nodeAttributes) {
    314      const el = this.attrElements.get(attr.name);
    315      const valueChanged = el && el.dataset.value !== attr.value;
    316      const isEditing = el && el.querySelector(".editable").inplaceEditor;
    317      const canSimplyShowEditor = el && (!valueChanged || isEditing);
    318 
    319      if (canSimplyShowEditor) {
    320        // Element already exists and doesn't need to be recreated.
    321        // Just show it (it's hidden by default).
    322        el.style.removeProperty("display");
    323      } else {
    324        // Create a new editor, because the value of an existing attribute
    325        // has changed.
    326        const attribute = this._createAttribute(attr, el);
    327        attribute.style.removeProperty("display");
    328 
    329        // Temporarily flash the attribute to highlight the change.
    330        // But not if this is the first time the editor instance has
    331        // been created.
    332        if (this.initialized) {
    333          this.flashAttribute(attr.name);
    334        }
    335      }
    336    }
    337 
    338    this.updateEventBadge();
    339    this.updateDisplayBadge();
    340    this.updateCustomBadge();
    341    this.updateScrollableBadge();
    342    this.updateContainerBadge();
    343    this.updateAnchorBadge();
    344    this.updateTextEditor();
    345    this.updateUnavailableChildren();
    346    this.updateOverflowBadge();
    347    this.updateOverflowHighlight();
    348  }
    349 
    350  updateEventBadge() {
    351    const showEventBadge = this.node.hasEventListeners;
    352    if (this._eventBadge && !showEventBadge) {
    353      this._eventBadge.remove();
    354      this._eventBadge = null;
    355    } else if (showEventBadge && !this._eventBadge) {
    356      this._createEventBadge();
    357    }
    358  }
    359 
    360  _createEventBadge() {
    361    this._eventBadge = this.doc.createElement("button");
    362    this._eventBadge.className = "inspector-badge interactive";
    363    this._eventBadge.dataset.event = "true";
    364    this._eventBadge.textContent = "event";
    365    this._eventBadge.title = INSPECTOR_L10N.getStr(
    366      "markupView.event.tooltiptext2"
    367    );
    368    this._eventBadge.setAttribute("aria-pressed", "false");
    369    // Badges order is [event][display][custom], insert event badge before others.
    370    this.elt.insertBefore(
    371      this._eventBadge,
    372      this._displayBadge || this._customBadge
    373    );
    374    this.markup.emit("badge-added-event");
    375  }
    376 
    377  updateScrollableBadge() {
    378    if (this.node.isScrollable && !this._scrollableBadge) {
    379      this._createScrollableBadge();
    380    } else if (this._scrollableBadge && !this.node.isScrollable) {
    381      this._scrollableBadge.remove();
    382      this._scrollableBadge = null;
    383    }
    384  }
    385 
    386  _createScrollableBadge() {
    387    const isInteractive =
    388      this.isOverflowDebuggingEnabled &&
    389      // Document elements cannot have interative scrollable badges since retrieval of their
    390      // overflow causing elements is not supported.
    391      !this.node.isDocumentElement;
    392 
    393    this._scrollableBadge = this.doc.createElement(
    394      isInteractive ? "button" : "div"
    395    );
    396    this._scrollableBadge.className = `inspector-badge scrollable-badge ${isInteractive ? "interactive" : ""}`;
    397    this._scrollableBadge.dataset.scrollable = "true";
    398    this._scrollableBadge.textContent = INSPECTOR_L10N.getStr(
    399      "markupView.scrollableBadge.label"
    400    );
    401    this._scrollableBadge.title = INSPECTOR_L10N.getStr(
    402      isInteractive
    403        ? "markupView.scrollableBadge.interactive.tooltip"
    404        : "markupView.scrollableBadge.tooltip"
    405    );
    406 
    407    if (isInteractive) {
    408      this._scrollableBadge.addEventListener(
    409        "click",
    410        this.onScrollableBadgeClick
    411      );
    412      this._scrollableBadge.setAttribute("aria-pressed", "false");
    413    }
    414    this.elt.insertBefore(this._scrollableBadge, this._customBadge);
    415  }
    416 
    417  /**
    418   * Update the markup display badge.
    419   */
    420  updateDisplayBadge() {
    421    const displayType = this.node.displayType;
    422    const showDisplayBadge = displayType in DISPLAY_TYPES;
    423 
    424    if (this._displayBadge && !showDisplayBadge) {
    425      this._displayBadge.remove();
    426      this._displayBadge = null;
    427    } else if (showDisplayBadge) {
    428      if (!this._displayBadge) {
    429        this._createDisplayBadge();
    430      }
    431 
    432      this._updateDisplayBadgeContent();
    433    }
    434  }
    435 
    436  _createDisplayBadge() {
    437    this._displayBadge = this.doc.createElement("button");
    438    this._displayBadge.className = "inspector-badge";
    439    this._displayBadge.addEventListener("click", this.onDisplayBadgeClick);
    440    // Badges order is [event][display][custom], insert display badge before custom.
    441    this.elt.insertBefore(this._displayBadge, this._customBadge);
    442  }
    443 
    444  _updateDisplayBadgeContent() {
    445    const displayType = this.node.displayType;
    446    this._displayBadge.textContent = displayType;
    447    this._displayBadge.dataset.display = displayType;
    448    this._displayBadge.title = DISPLAY_TYPES[displayType];
    449 
    450    const isFlex = displayType === "flex" || displayType === "inline-flex";
    451    const isGrid =
    452      displayType === "grid" ||
    453      displayType === "inline-grid" ||
    454      displayType === "subgrid";
    455 
    456    const isInteractive =
    457      isFlex ||
    458      (isGrid && this.highlighters.canGridHighlighterToggle(this.node));
    459 
    460    this._displayBadge.classList.toggle("interactive", isInteractive);
    461 
    462    // Since the badge is a <button>, if it's not interactive we need to indicate
    463    // to screen readers that it shouldn't behave like a button.
    464    // It's easier to have the badge being a button and "downgrading" it like this,
    465    // than having it as a div and adding interactivity.
    466    if (isInteractive) {
    467      this._displayBadge.removeAttribute("role");
    468      this._displayBadge.setAttribute("aria-pressed", "false");
    469    } else {
    470      this._displayBadge.setAttribute("role", "presentation");
    471      this._displayBadge.removeAttribute("aria-pressed");
    472    }
    473  }
    474 
    475  updateOverflowBadge() {
    476    if (!this.isOverflowDebuggingEnabled) {
    477      return;
    478    }
    479 
    480    if (this.node.causesOverflow && !this._overflowBadge) {
    481      this._createOverflowBadge();
    482    } else if (!this.node.causesOverflow && this._overflowBadge) {
    483      this._overflowBadge.remove();
    484      this._overflowBadge = null;
    485    }
    486  }
    487 
    488  _createOverflowBadge() {
    489    this._overflowBadge = this.doc.createElement("div");
    490    this._overflowBadge.className = "inspector-badge overflow-badge";
    491    this._overflowBadge.textContent = INSPECTOR_L10N.getStr(
    492      "markupView.overflowBadge.label"
    493    );
    494    this._overflowBadge.title = INSPECTOR_L10N.getStr(
    495      "markupView.overflowBadge.tooltip"
    496    );
    497    this.elt.insertBefore(this._overflowBadge, this._customBadge);
    498  }
    499 
    500  /**
    501   * Update the markup custom element badge.
    502   */
    503  updateCustomBadge() {
    504    const showCustomBadge = !!this.node.customElementLocation;
    505    if (this._customBadge && !showCustomBadge) {
    506      this._customBadge.remove();
    507      this._customBadge = null;
    508    } else if (!this._customBadge && showCustomBadge) {
    509      this._createCustomBadge();
    510    }
    511  }
    512 
    513  _createCustomBadge() {
    514    this._customBadge = this.doc.createElement("button");
    515    this._customBadge.className = "inspector-badge interactive";
    516    this._customBadge.dataset.custom = "true";
    517    this._customBadge.textContent = "custom…";
    518    this._customBadge.title = INSPECTOR_L10N.getStr(
    519      "markupView.custom.tooltiptext"
    520    );
    521    this._customBadge.addEventListener("click", this.onCustomBadgeClick);
    522    // Badges order is [event][display][custom], insert custom badge at the end.
    523    this.elt.appendChild(this._customBadge);
    524  }
    525 
    526  updateContainerBadge() {
    527    const showContainerBadge =
    528      this.node.containerType === "inline-size" ||
    529      this.node.containerType === "size";
    530 
    531    if (this._containerBadge && !showContainerBadge) {
    532      this._containerBadge.remove();
    533      this._containerBadge = null;
    534    } else if (showContainerBadge && !this._containerBadge) {
    535      this._createContainerBadge();
    536    }
    537  }
    538 
    539  _createContainerBadge() {
    540    this._containerBadge = this.doc.createElement("div");
    541    this._containerBadge.classList.add("inspector-badge");
    542    this._containerBadge.dataset.container = "true";
    543    this._containerBadge.title = `container-type: ${this.node.containerType}`;
    544 
    545    this._containerBadge.append(this.doc.createTextNode("container"));
    546    // TODO: Move the logic to handle badges position in a dedicated helper (See Bug 1837921).
    547    // Ideally badges order should be [event][display][container][custom]
    548    this.elt.insertBefore(this._containerBadge, this._customBadge);
    549    this.markup.emit("badge-added-event");
    550  }
    551 
    552  updateAnchorBadge() {
    553    const showAnchorBadge = this.node.anchorName?.includes?.("--");
    554 
    555    if (this._anchorBadge && !showAnchorBadge) {
    556      this._anchorBadge.remove();
    557      this._anchorBadge = null;
    558    } else if (showAnchorBadge && !this._anchorBadge) {
    559      this._createAnchorBadge();
    560    }
    561 
    562    if (this._anchorBadge) {
    563      this._anchorBadge.title = `anchor-name: ${this.node.anchorName}`;
    564    }
    565  }
    566 
    567  _createAnchorBadge() {
    568    this._anchorBadge = this.doc.createElement("div");
    569    this._anchorBadge.classList.add("inspector-badge");
    570    this._anchorBadge.dataset.anchor = "true";
    571 
    572    this._anchorBadge.append(this.doc.createTextNode("anchor"));
    573    this.elt.insertBefore(this._anchorBadge, this._containerBadge);
    574  }
    575 
    576  /**
    577   * If node causes overflow, toggle its overflow highlight if its scrollable ancestor's
    578   * scrollable badge is active/inactive.
    579   */
    580  async updateOverflowHighlight() {
    581    if (!this.isOverflowDebuggingEnabled) {
    582      return;
    583    }
    584 
    585    let showOverflowHighlight = false;
    586 
    587    if (this.node.causesOverflow) {
    588      try {
    589        const scrollableAncestor =
    590          await this.node.walkerFront.getScrollableAncestorNode(this.node);
    591        const markupContainer = scrollableAncestor
    592          ? this.markup.getContainer(scrollableAncestor)
    593          : null;
    594 
    595        showOverflowHighlight =
    596          !!markupContainer?.editor.highlightingOverflowCausingElements;
    597      } catch (e) {
    598        // This call might fail if called asynchrously after the toolbox is finished
    599        // closing.
    600        return;
    601      }
    602    }
    603 
    604    this.setOverflowHighlight(showOverflowHighlight);
    605  }
    606 
    607  /**
    608   * Show overflow highlight if showOverflowHighlight is true, otherwise hide it.
    609   *
    610   * @param {boolean} showOverflowHighlight
    611   */
    612  setOverflowHighlight(showOverflowHighlight) {
    613    this.container.tagState.classList.toggle(
    614      "overflow-causing-highlighted",
    615      showOverflowHighlight
    616    );
    617  }
    618 
    619  /**
    620   * Update the inline text editor in case of a single text child node.
    621   */
    622  updateTextEditor() {
    623    const node = this.node.inlineTextChild;
    624 
    625    if (this.textEditor && this.textEditor.node != node) {
    626      this.elt.removeChild(this.textEditor.elt);
    627      this.textEditor.destroy();
    628      this.textEditor = null;
    629    }
    630 
    631    if (node && !this.textEditor) {
    632      // Create a text editor added to this editor.
    633      // This editor won't receive an update automatically, so we rely on
    634      // child text editors to let us know that we need updating.
    635      this.textEditor = new TextEditor(this.container, node, "text");
    636      this.elt.insertBefore(
    637        this.textEditor.elt,
    638        this.elt.querySelector(".close")
    639      );
    640    }
    641 
    642    if (this.textEditor) {
    643      this.textEditor.update();
    644    }
    645  }
    646 
    647  hasUnavailableChildren() {
    648    return !!this.childrenUnavailableElt;
    649  }
    650 
    651  /**
    652   * Update a special badge displayed for nodes which have children that can't
    653   * be inspected by the current session (eg a parent-process only toolbox
    654   * inspecting a content browser).
    655   */
    656  updateUnavailableChildren() {
    657    const childrenUnavailable = this.node.childrenUnavailable;
    658 
    659    if (this.childrenUnavailableElt) {
    660      this.elt.removeChild(this.childrenUnavailableElt);
    661      this.childrenUnavailableElt = null;
    662    }
    663 
    664    if (childrenUnavailable) {
    665      this.childrenUnavailableElt = this.doc.createElement("div");
    666      this.childrenUnavailableElt.className = "unavailable-children";
    667      this.childrenUnavailableElt.dataset.label = INSPECTOR_L10N.getStr(
    668        "markupView.unavailableChildren.label"
    669      );
    670      this.childrenUnavailableElt.title = INSPECTOR_L10N.getStr(
    671        "markupView.unavailableChildren.title"
    672      );
    673      this.elt.insertBefore(
    674        this.childrenUnavailableElt,
    675        this.elt.querySelector(".close")
    676      );
    677    }
    678  }
    679 
    680  _startModifyingAttributes() {
    681    return this.node.startModifyingAttributes();
    682  }
    683 
    684  /**
    685   * Get the element used for one of the attributes of this element.
    686   *
    687   * @param  {string} attrName
    688   *         The name of the attribute to get the element for
    689   * @return {DOMNode}
    690   */
    691  getAttributeElement(attrName) {
    692    return this.attrList.querySelector(
    693      ".attreditor[data-attr=" + CSS.escape(attrName) + "] .attr-value"
    694    );
    695  }
    696 
    697  /**
    698   * Remove an attribute from the attrElements object and the DOM.
    699   *
    700   * @param  {string} attrName
    701   *         The name of the attribute to remove
    702   */
    703  removeAttribute(attrName) {
    704    const attr = this.attrElements.get(attrName);
    705    if (attr) {
    706      this.attrElements.delete(attrName);
    707      attr.remove();
    708    }
    709  }
    710 
    711  /**
    712   * Creates and returns the DOM for displaying an attribute with the following DOM
    713   * structure:
    714   *
    715   * dom.span(
    716   *   {
    717   *     className: "attreditor",
    718   *     "data-attr": attribute.name,
    719   *     "data-value": attribute.value,
    720   *   },
    721   *   " ",
    722   *   dom.span(
    723   *     { className: "editable", tabIndex: 0 },
    724   *     dom.span({ className: "attr-name" }, attribute.name),
    725   *     '="',
    726   *     dom.span({ className: "attr-value" }, attribute.value),
    727   *     '"'
    728   *   )
    729   */
    730  _createAttribute(attribute, before = null) {
    731    const attr = this.doc.createElement("span");
    732    attr.dataset.attr = attribute.name;
    733    attr.dataset.value = attribute.value;
    734    attr.classList.add("attreditor");
    735    attr.style.display = "none";
    736 
    737    attr.appendChild(this.doc.createTextNode(" "));
    738 
    739    const inner = this.doc.createElement("span");
    740    inner.classList.add("editable");
    741    inner.setAttribute("tabindex", this.container.canFocus ? "0" : "-1");
    742    attr.appendChild(inner);
    743 
    744    const name = this.doc.createElement("span");
    745    name.classList.add("attr-name", "force-color-on-flash");
    746    name.textContent = attribute.name;
    747    inner.appendChild(name);
    748 
    749    inner.appendChild(this.doc.createTextNode('="'));
    750 
    751    const val = this.doc.createElement("span");
    752    val.classList.add("attr-value", "force-color-on-flash");
    753    inner.appendChild(val);
    754 
    755    inner.appendChild(this.doc.createTextNode('"'));
    756 
    757    this._setupAttributeEditor(attribute, attr, inner, name, val);
    758 
    759    // Figure out where we should place the attribute.
    760    if (attribute.name == "id") {
    761      before = this.attrList.firstChild;
    762    } else if (attribute.name == "class") {
    763      const idNode = this.attrElements.get("id");
    764      before = idNode ? idNode.nextSibling : this.attrList.firstChild;
    765    }
    766    this.attrList.insertBefore(attr, before);
    767 
    768    this.removeAttribute(attribute.name);
    769    this.attrElements.set(attribute.name, attr);
    770 
    771    this._appendAttributeValue(attribute, val);
    772 
    773    return attr;
    774  }
    775 
    776  /**
    777   * Setup the editable field for the given attribute.
    778   *
    779   * @param  {object} attribute
    780   *         An object containing the name and value of a DOM attribute.
    781   * @param  {Element} attrEditorEl
    782   *         The attribute container <span class="attreditor"> element.
    783   * @param  {Element} editableEl
    784   *         The editable <span class="editable"> element that is setup to be
    785   *         an editable field.
    786   * @param  {Element} attrNameEl
    787   *         The attribute name <span class="attr-name"> element.
    788   * @param  {Element} attrValueEl
    789   *         The attribute value <span class="attr-value"> element.
    790   */
    791  _setupAttributeEditor(
    792    attribute,
    793    attrEditorEl,
    794    editableEl,
    795    attrNameEl,
    796    attrValueEl
    797  ) {
    798    // Double quotes need to be handled specially to prevent DOMParser failing.
    799    // name="v"a"l"u"e" when editing -> name='v"a"l"u"e"'
    800    // name="v'a"l'u"e" when editing -> name="v'a&quot;l'u&quot;e"
    801    let editValueDisplayed = attribute.value || "";
    802    const hasDoubleQuote = editValueDisplayed.includes('"');
    803    const hasSingleQuote = editValueDisplayed.includes("'");
    804    let initial = attribute.name + '="' + editValueDisplayed + '"';
    805 
    806    // Can't just wrap value with ' since the value contains both " and '.
    807    if (hasDoubleQuote && hasSingleQuote) {
    808      editValueDisplayed = editValueDisplayed.replace(/\"/g, "&quot;");
    809      initial = attribute.name + '="' + editValueDisplayed + '"';
    810    }
    811 
    812    // Wrap with ' since there are no single quotes in the attribute value.
    813    if (hasDoubleQuote && !hasSingleQuote) {
    814      initial = attribute.name + "='" + editValueDisplayed + "'";
    815    }
    816 
    817    // Make the attribute editable.
    818    attrEditorEl.editMode = editableField({
    819      element: editableEl,
    820      trigger: "dblclick",
    821      stopOnReturn: true,
    822      selectAll: false,
    823      initial,
    824      multiline: true,
    825      maxWidth: () => getAutocompleteMaxWidth(editableEl, this.container.elt),
    826      contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
    827      popup: this.markup.popup,
    828      start: (editor, event) => {
    829        // If the editing was started inside the name or value areas,
    830        // select accordingly.
    831        if (event?.target === attrNameEl) {
    832          editor.input.setSelectionRange(0, attrNameEl.textContent.length);
    833        } else if (event?.target.closest(".attr-value") === attrValueEl) {
    834          const length = editValueDisplayed.length;
    835          const editorLength = editor.input.value.length;
    836          const start = editorLength - (length + 1);
    837          editor.input.setSelectionRange(start, start + length);
    838        } else {
    839          editor.input.select();
    840        }
    841      },
    842      done: (newValue, commit, direction) => {
    843        if (!commit || newValue === initial) {
    844          return;
    845        }
    846 
    847        const doMods = this._startModifyingAttributes();
    848        const undoMods = this._startModifyingAttributes();
    849 
    850        // Remove the attribute stored in this editor and re-add any attributes
    851        // parsed out of the input element. Restore original attribute if
    852        // parsing fails.
    853        this.refocusOnEdit(attribute.name, attrEditorEl, direction);
    854        this._saveAttribute(attribute.name, undoMods);
    855        doMods.removeAttribute(attribute.name);
    856        this._applyAttributes(newValue, attrEditorEl, doMods, undoMods);
    857        this.container.undo.do(
    858          () => {
    859            doMods.apply();
    860          },
    861          () => {
    862            undoMods.apply();
    863          }
    864        );
    865      },
    866      cssProperties: this._cssProperties,
    867    });
    868  }
    869 
    870  /**
    871   * Appends the attribute value to the given attribute value <span> element.
    872   *
    873   * @param  {object} attribute
    874   *         An object containing the name and value of a DOM attribute.
    875   * @param  {Element} attributeValueEl
    876   *         The attribute value <span class="attr-value"> element to append
    877   *         the parsed attribute values to.
    878   */
    879  _appendAttributeValue(attribute, attributeValueEl) {
    880    // Parse the attribute value to detect whether there are linkable parts in
    881    // it (make sure to pass a complete list of existing attributes to the
    882    // parseAttribute function, by concatenating attribute, because this could
    883    // be a newly added attribute not yet on this.node).
    884    const attributes = this.node.attributes.filter(
    885      existingAttribute => existingAttribute.name !== attribute.name
    886    );
    887    attributes.push(attribute);
    888 
    889    const parsedLinksData = parseAttribute(
    890      this.node.namespaceURI,
    891      this.node.tagName,
    892      attributes,
    893      attribute.name,
    894      attribute.value
    895    );
    896 
    897    attributeValueEl.innerHTML = "";
    898 
    899    // Create links in the attribute value, and truncate long attribute values if needed.
    900    for (const token of parsedLinksData) {
    901      if (token.type === "string" || token.value?.trim() === "") {
    902        attributeValueEl.appendChild(
    903          this.doc.createTextNode(this._truncateAttributeValue(token.value))
    904        );
    905      } else {
    906        const link = this.doc.createElement("span");
    907        link.classList.add("link");
    908        link.setAttribute("data-type", token.type);
    909        link.setAttribute("data-link", token.value);
    910        link.textContent = this._truncateAttributeValue(token.value);
    911        attributeValueEl.append(link);
    912 
    913        // Add a "select node" button when we reference element ids
    914        if (
    915          token.type === ATTRIBUTE_TYPES.TYPE_IDREF ||
    916          token.type === ATTRIBUTE_TYPES.TYPE_IDREF_LIST
    917        ) {
    918          const button = this.doc.createElement("button");
    919          button.classList.add("select-node");
    920          button.setAttribute(
    921            "title",
    922            INSPECTOR_L10N.getFormatStr(
    923              "inspector.menu.selectElement.label",
    924              token.value
    925            )
    926          );
    927          link.append(button);
    928        }
    929      }
    930    }
    931  }
    932 
    933  /**
    934   * Truncates the given attribute value if it is a base64 data URL or the
    935   * collapse attributes pref is enabled.
    936   *
    937   * @param  {string} value
    938   *         Attribute value.
    939   * @return {string} truncated attribute value.
    940   */
    941  _truncateAttributeValue(value) {
    942    if (value && value.match(COLLAPSE_DATA_URL_REGEX)) {
    943      return truncateString(value, COLLAPSE_DATA_URL_LENGTH);
    944    }
    945 
    946    return this.markup.collapseAttributes
    947      ? truncateString(value, this.markup.collapseAttributeLength)
    948      : value;
    949  }
    950 
    951  /**
    952   * Parse a user-entered attribute string and apply the resulting
    953   * attributes to the node. This operation is undoable.
    954   *
    955   * @param  {string} value
    956   *         The user-entered value.
    957   * @param  {DOMNode} attrNode
    958   *         The attribute editor that created this
    959   *         set of attributes, used to place new attributes where the
    960   *         user put them.
    961   */
    962  _applyAttributes(value, attrNode, doMods, undoMods) {
    963    const attrs = parseAttributeValues(value, this.doc);
    964    for (const attr of attrs) {
    965      // Create an attribute editor next to the current attribute if needed.
    966      this._createAttribute(attr, attrNode ? attrNode.nextSibling : null);
    967      this._saveAttribute(attr.name, undoMods);
    968      doMods.setAttribute(attr.name, attr.value);
    969    }
    970  }
    971 
    972  /**
    973   * Saves the current state of the given attribute into an attribute
    974   * modification list.
    975   */
    976  _saveAttribute(name, undoMods) {
    977    const node = this.node;
    978    if (node.hasAttribute(name)) {
    979      const oldValue = node.getAttribute(name);
    980      undoMods.setAttribute(name, oldValue);
    981    } else {
    982      undoMods.removeAttribute(name);
    983    }
    984  }
    985 
    986  /**
    987   * Listen to mutations, and when the attribute list is regenerated
    988   * try to focus on the attribute after the one that's being edited now.
    989   * If the attribute order changes, go to the beginning of the attribute list.
    990   */
    991  refocusOnEdit(attrName, attrNode, direction) {
    992    // Only allow one refocus on attribute change at a time, so when there's
    993    // more than 1 request in parallel, the last one wins.
    994    if (this._editedAttributeObserver) {
    995      this.markup.inspector.off(
    996        "markupmutation",
    997        this._editedAttributeObserver
    998      );
    999      this._editedAttributeObserver = null;
   1000    }
   1001 
   1002    const activeElement = this.markup.doc.activeElement;
   1003    if (!activeElement || !activeElement.inplaceEditor) {
   1004      // The focus was already removed from the current inplace editor, we should not
   1005      // refocus the editable attribute.
   1006      return;
   1007    }
   1008 
   1009    const container = this.markup.getContainer(this.node);
   1010 
   1011    const activeAttrs = [...this.attrList.childNodes].filter(
   1012      el => el.style.display != "none"
   1013    );
   1014    const attributeIndex = activeAttrs.indexOf(attrNode);
   1015 
   1016    const onMutations = (this._editedAttributeObserver = mutations => {
   1017      let isDeletedAttribute = false;
   1018      let isNewAttribute = false;
   1019 
   1020      for (const mutation of mutations) {
   1021        const inContainer =
   1022          this.markup.getContainer(mutation.target) === container;
   1023        if (!inContainer) {
   1024          continue;
   1025        }
   1026 
   1027        const isOriginalAttribute = mutation.attributeName === attrName;
   1028 
   1029        isDeletedAttribute =
   1030          isDeletedAttribute ||
   1031          (isOriginalAttribute && mutation.newValue === null);
   1032        isNewAttribute = isNewAttribute || mutation.attributeName !== attrName;
   1033      }
   1034 
   1035      const isModifiedOrder = isDeletedAttribute && isNewAttribute;
   1036      this._editedAttributeObserver = null;
   1037 
   1038      // "Deleted" attributes are merely hidden, so filter them out.
   1039      const visibleAttrs = [...this.attrList.childNodes].filter(
   1040        el => el.style.display != "none"
   1041      );
   1042      let activeEditor;
   1043      if (visibleAttrs.length) {
   1044        if (!direction) {
   1045          // No direction was given; stay on current attribute.
   1046          activeEditor = visibleAttrs[attributeIndex];
   1047        } else if (isModifiedOrder) {
   1048          // The attribute was renamed, reordering the existing attributes.
   1049          // So let's go to the beginning of the attribute list for consistency.
   1050          activeEditor = visibleAttrs[0];
   1051        } else {
   1052          let newAttributeIndex;
   1053          if (isDeletedAttribute) {
   1054            newAttributeIndex = attributeIndex;
   1055          } else if (direction == Services.focus.MOVEFOCUS_FORWARD) {
   1056            newAttributeIndex = attributeIndex + 1;
   1057          } else if (direction == Services.focus.MOVEFOCUS_BACKWARD) {
   1058            newAttributeIndex = attributeIndex - 1;
   1059          }
   1060 
   1061          // The number of attributes changed (deleted), or we moved through
   1062          // the array so check we're still within bounds.
   1063          if (
   1064            newAttributeIndex >= 0 &&
   1065            newAttributeIndex <= visibleAttrs.length - 1
   1066          ) {
   1067            activeEditor = visibleAttrs[newAttributeIndex];
   1068          }
   1069        }
   1070      }
   1071 
   1072      // Either we have no attributes left,
   1073      // or we just edited the last attribute and want to move on.
   1074      if (!activeEditor) {
   1075        activeEditor = this.newAttr;
   1076      }
   1077 
   1078      // Refocus was triggered by tab or shift-tab.
   1079      // Continue in edit mode.
   1080      if (direction) {
   1081        activeEditor.editMode();
   1082      } else {
   1083        // Refocus was triggered by enter.
   1084        // Exit edit mode (but restore focus).
   1085        const editable =
   1086          activeEditor === this.newAttr
   1087            ? activeEditor
   1088            : activeEditor.querySelector(".editable");
   1089        editable.focus();
   1090      }
   1091 
   1092      this.markup.emit("refocusedonedit");
   1093    });
   1094 
   1095    // Start listening for mutations until we find an attributes change
   1096    // that modifies this attribute.
   1097    this.markup.inspector.once("markupmutation", onMutations);
   1098  }
   1099 
   1100  /**
   1101   * Called when the display badge is clicked. Toggles on the flexbox/grid highlighter for
   1102   * the selected node if it is a grid container.
   1103   *
   1104   * Event handling for highlighter events is delegated up to the Markup view panel.
   1105   * When a flexbox/grid highlighter is shown or hidden, the corresponding badge will
   1106   * be marked accordingly. See MarkupView.handleHighlighterEvent()
   1107   */
   1108  async onDisplayBadgeClick(event) {
   1109    event.stopPropagation();
   1110 
   1111    const target = event.target;
   1112 
   1113    if (
   1114      target.dataset.display === "flex" ||
   1115      target.dataset.display === "inline-flex"
   1116    ) {
   1117      await this.highlighters.toggleFlexboxHighlighter(this.node, "markup");
   1118    }
   1119 
   1120    if (
   1121      target.dataset.display === "grid" ||
   1122      target.dataset.display === "inline-grid" ||
   1123      target.dataset.display === "subgrid"
   1124    ) {
   1125      // Don't toggle the grid highlighter if the max number of new grid highlighters
   1126      // allowed has been reached.
   1127      if (!this.highlighters.canGridHighlighterToggle(this.node)) {
   1128        return;
   1129      }
   1130 
   1131      await this.highlighters.toggleGridHighlighter(this.node, "markup");
   1132    }
   1133  }
   1134 
   1135  async onCustomBadgeClick() {
   1136    const { url, line, column } = this.node.customElementLocation;
   1137 
   1138    this.markup.toolbox.viewSourceInDebugger(
   1139      url,
   1140      line,
   1141      column,
   1142      null,
   1143      "show_custom_element"
   1144    );
   1145  }
   1146 
   1147  onExpandBadgeClick() {
   1148    this.container.expandContainer();
   1149  }
   1150 
   1151  /**
   1152   * Called when the scrollable badge is clicked. Shows the overflow causing elements and
   1153   * highlights their container if the scroll badge is active.
   1154   */
   1155  async onScrollableBadgeClick() {
   1156    this.highlightingOverflowCausingElements =
   1157      this._scrollableBadge.classList.toggle("active");
   1158    this._scrollableBadge.setAttribute(
   1159      "aria-pressed",
   1160      this.highlightingOverflowCausingElements
   1161    );
   1162 
   1163    const { nodes } = await this.node.walkerFront.getOverflowCausingElements(
   1164      this.node
   1165    );
   1166 
   1167    for (const node of nodes) {
   1168      if (this.highlightingOverflowCausingElements) {
   1169        await this.markup.showNode(node);
   1170      }
   1171 
   1172      const markupContainer = this.markup.getContainer(node);
   1173 
   1174      if (markupContainer) {
   1175        markupContainer.editor.setOverflowHighlight(
   1176          this.highlightingOverflowCausingElements
   1177        );
   1178      }
   1179    }
   1180 
   1181    Glean.devtoolsMarkupScrollableBadge.clicked.add(1);
   1182  }
   1183 
   1184  /**
   1185   * Called when the tag name editor has is done editing.
   1186   */
   1187  async onTagEdit(inputValue, isCommit) {
   1188    if (!isCommit) {
   1189      return;
   1190    }
   1191 
   1192    inputValue = inputValue.trim();
   1193    const spaceIndex = inputValue.indexOf(" ");
   1194    const newTagName =
   1195      spaceIndex === -1 ? inputValue : inputValue.substring(0, spaceIndex);
   1196 
   1197    const shouldUpdateTagName =
   1198      newTagName.toLowerCase() !== this.node.tagName.toLowerCase();
   1199 
   1200    // If there is content after the tagName, we could have attributes that we need to set
   1201    // Changing the tag name removes the node, so set the attributes first, then they
   1202    // will be copied in `editTagName`
   1203    const newAttributes =
   1204      spaceIndex === -1 ? null : inputValue.substring(spaceIndex + 1).trim();
   1205    if (newAttributes?.length) {
   1206      const doMods = this._startModifyingAttributes();
   1207      const undoMods = this._startModifyingAttributes();
   1208      this._applyAttributes(newAttributes, null, doMods, undoMods);
   1209      // if the tagName will be changed, a new node will be created, and we don't handle
   1210      // undo for this, so we can directly set the attributes.
   1211      if (shouldUpdateTagName) {
   1212        await doMods.apply();
   1213        undoMods.destroy();
   1214      } else {
   1215        this.container.undo.do(
   1216          () => doMods.apply(),
   1217          () => undoMods.apply()
   1218        );
   1219      }
   1220    }
   1221 
   1222    if (!shouldUpdateTagName) {
   1223      return;
   1224    }
   1225 
   1226    // Changing the tagName removes the node. Make sure the replacing node gets
   1227    // selected afterwards.
   1228    this.markup.reselectOnRemoved(this.node, "edittagname");
   1229    try {
   1230      await this.node.walkerFront.editTagName(this.node, newTagName);
   1231    } catch (e) {
   1232      // Failed to edit the tag name, cancel the reselection.
   1233      this.markup.cancelReselectOnRemoved();
   1234    }
   1235  }
   1236 
   1237  destroy() {
   1238    if (this._displayBadge) {
   1239      this._displayBadge.removeEventListener("click", this.onDisplayBadgeClick);
   1240    }
   1241 
   1242    if (this._customBadge) {
   1243      this._customBadge.removeEventListener("click", this.onCustomBadgeClick);
   1244    }
   1245 
   1246    if (this._scrollableBadge) {
   1247      this._scrollableBadge.removeEventListener(
   1248        "click",
   1249        this.onScrollableBadgeClick
   1250      );
   1251    }
   1252 
   1253    this.expandBadge.removeEventListener("click", this.onExpandBadgeClick);
   1254 
   1255    for (const key in this.animationTimers) {
   1256      clearTimeout(this.animationTimers[key]);
   1257    }
   1258    this.animationTimers = null;
   1259  }
   1260 }
   1261 
   1262 module.exports = ElementEditor;