/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const TextEditor = require("resource://devtools/client/inspector/markup/views/text-editor.js");
const {
  truncateString,
} = require("resource://devtools/shared/inspector/utils.js");
const {
  editableField,
  InplaceEditor,
} = require("resource://devtools/client/shared/inplace-editor.js");
const {
  parseAttribute,
  ATTRIBUTE_TYPES,
} = require("resource://devtools/client/shared/node-attribute-parser.js");

loader.lazyRequireGetter(
  this,
  [
    "flashElementOn",
    "flashElementOff",
    "getAutocompleteMaxWidth",
    "parseAttributeValues",
  ],
  "resource://devtools/client/inspector/markup/utils.js",
  true
);

const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
const INSPECTOR_L10N = new LocalizationHelper(
  "devtools/client/locales/inspector.properties"
);

// Page size for pageup/pagedown
const COLLAPSE_DATA_URL_REGEX = /^data.+base64/;
const COLLAPSE_DATA_URL_LENGTH = 60;

// Contains only void (without end tag) HTML elements
const HTML_VOID_ELEMENTS = [
  "area",
  "base",
  "br",
  "col",
  "command",
  "embed",
  "hr",
  "img",
  "input",
  "keygen",
  "link",
  "meta",
  "param",
  "source",
  "track",
  "wbr",
];

// Contains only valid computed display property types of the node to display in the
// element markup and their respective title tooltip text.
const DISPLAY_TYPES = {
  flex: INSPECTOR_L10N.getStr("markupView.display.flex.tooltiptext2"),
  "inline-flex": INSPECTOR_L10N.getStr(
    "markupView.display.inlineFlex.tooltiptext2"
  ),
  grid: INSPECTOR_L10N.getStr("markupView.display.grid.tooltiptext2"),
  "inline-grid": INSPECTOR_L10N.getStr(
    "markupView.display.inlineGrid.tooltiptext2"
  ),
  subgrid: INSPECTOR_L10N.getStr("markupView.display.subgrid.tooltiptiptext"),
  "flow-root": INSPECTOR_L10N.getStr("markupView.display.flowRoot.tooltiptext"),
  contents: INSPECTOR_L10N.getStr("markupView.display.contents.tooltiptext2"),
};

/**
 * Creates an editor for an Element node.
 *
 * @param  {MarkupContainer} container
 *         The container owning this editor.
 * @param  {NodeFront} node
 *         The NodeFront being edited.
 */
function ElementEditor(container, node) {
  this.container = container;
  this.node = node;
  this.markup = this.container.markup;
  this.doc = this.markup.doc;
  this.inspector = this.markup.inspector;
  this.highlighters = this.markup.highlighters;
  this._cssProperties = this.inspector.cssProperties;

  this.isOverflowDebuggingEnabled = Services.prefs.getBoolPref(
    "devtools.overflow.debugging.enabled"
  );

  // If this is a scrollable element, this specifies whether or not its overflow causing
  // elements are highlighted. Otherwise, it is null if the element is not scrollable.
  this.highlightingOverflowCausingElements = this.node.isScrollable
    ? false
    : null;

  this.attrElements = new Map();
  this.animationTimers = {};

  this.elt = null;
  this.tag = null;
  this.closeTag = null;
  this.attrList = null;
  this.newAttr = null;
  this.closeElt = null;

  this.onCustomBadgeClick = this.onCustomBadgeClick.bind(this);
  this.onDisplayBadgeClick = this.onDisplayBadgeClick.bind(this);
  this.onScrollableBadgeClick = this.onScrollableBadgeClick.bind(this);
  this.onExpandBadgeClick = this.onExpandBadgeClick.bind(this);
  this.onTagEdit = this.onTagEdit.bind(this);

  this.buildMarkup();

  const isVoidElement = HTML_VOID_ELEMENTS.includes(this.node.displayName);
  if (node.isInHTMLDocument && isVoidElement) {
    this.elt.classList.add("void-element");
  }

  this.update();
  this.initialized = true;
}

ElementEditor.prototype = {
  buildMarkup() {
    this.elt = this.doc.createElement("span");
    this.elt.classList.add("editor");

    this.renderOpenTag();
    this.renderEventBadge();
    this.renderCloseTag();

    // Make the tag name editable (unless this is a remote node or
    // a document element)
    if (!this.node.isDocumentElement) {
      // Make the tag optionally tabbable but not by default.
      this.tag.setAttribute("tabindex", "-1");
      editableField({
        element: this.tag,
        multiline: true,
        maxWidth: () => getAutocompleteMaxWidth(this.tag, this.container.elt),
        trigger: "dblclick",
        stopOnReturn: true,
        done: this.onTagEdit,
        cssProperties: this._cssProperties,
      });
    }
  },

  renderOpenTag() {
    const open = this.doc.createElement("span");
    open.classList.add("open");
    open.appendChild(this.doc.createTextNode("<"));
    this.elt.appendChild(open);

    this.tag = this.doc.createElement("span");
    this.tag.classList.add("tag", "force-color-on-flash");
    this.tag.setAttribute("tabindex", "-1");
    this.tag.textContent = this.node.displayName;
    open.appendChild(this.tag);

    this.renderAttributes(open);
    this.renderNewAttributeEditor(open);

    const closingBracket = this.doc.createElement("span");
    closingBracket.classList.add("closing-bracket");
    closingBracket.textContent = ">";
    open.appendChild(closingBracket);
  },

  renderAttributes(containerEl) {
    this.attrList = this.doc.createElement("span");
    containerEl.appendChild(this.attrList);
  },

  renderNewAttributeEditor(containerEl) {
    this.newAttr = this.doc.createElement("span");
    this.newAttr.classList.add("newattr");
    this.newAttr.setAttribute("tabindex", "-1");
    this.newAttr.setAttribute(
      "aria-label",
      INSPECTOR_L10N.getStr("markupView.newAttribute.label")
    );
    containerEl.appendChild(this.newAttr);

    // Make the new attribute space editable.
    this.newAttr.editMode = editableField({
      element: this.newAttr,
      multiline: true,
      inputClass: "newattr-input",
      maxWidth: () => getAutocompleteMaxWidth(this.newAttr, this.container.elt),
      trigger: "dblclick",
      stopOnReturn: true,
      contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
      popup: this.markup.popup,
      done: (val, commit) => {
        if (!commit) {
          return;
        }

        const doMods = this._startModifyingAttributes();
        const undoMods = this._startModifyingAttributes();
        this._applyAttributes(val, null, doMods, undoMods);
        this.container.undo.do(
          () => {
            doMods.apply();
          },
          function () {
            undoMods.apply();
          }
        );
      },
      cssProperties: this._cssProperties,
    });
  },

  renderEventBadge() {
    this.expandBadge = this.doc.createElement("span");
    this.expandBadge.classList.add("markup-expand-badge");
    this.expandBadge.addEventListener("click", this.onExpandBadgeClick);
    this.elt.appendChild(this.expandBadge);
  },

  renderCloseTag() {
    const close = this.doc.createElement("span");
    close.classList.add("close");
    close.appendChild(this.doc.createTextNode("</"));
    this.elt.appendChild(close);

    this.closeTag = this.doc.createElement("span");
    this.closeTag.classList.add("tag", "force-color-on-flash");
    this.closeTag.textContent = this.node.displayName;
    close.appendChild(this.closeTag);

    close.appendChild(this.doc.createTextNode(">"));
  },

  get displayBadge() {
    return this._displayBadge;
  },

  set selected(value) {
    if (this.textEditor) {
      this.textEditor.selected = value;
    }
  },

  flashAttribute(attrName) {
    if (this.animationTimers[attrName]) {
      clearTimeout(this.animationTimers[attrName]);
    }

    flashElementOn(this.getAttributeElement(attrName), {
      backgroundClass: "theme-bg-contrast",
    });

    this.animationTimers[attrName] = setTimeout(() => {
      flashElementOff(this.getAttributeElement(attrName), {
        backgroundClass: "theme-bg-contrast",
      });
    }, this.markup.CONTAINER_FLASHING_DURATION);
  },

  /**
   * Returns information about node in the editor.
   *
   * @param  {DOMNode} node
   *         The node to get information from.
   * @return {Object} An object literal with the following information:
   *         {type: "attribute", name: "rel", value: "index", el: node}
   */
  getInfoAtNode(node) {
    if (!node) {
      return null;
    }

    let type = null;
    let name = null;
    let value = null;

    // Attribute
    const attribute = node.closest(".attreditor");
    if (attribute) {
      type = "attribute";
      name = attribute.dataset.attr;
      value = attribute.dataset.value;
    }

    return { type, name, value, el: node };
  },

  /**
   * Update the state of the editor from the node.
   */
  update() {
    const nodeAttributes = this.node.attributes || [];

    // Keep the data model in sync with attributes on the node.
    const currentAttributes = new Set(nodeAttributes.map(a => a.name));
    for (const name of this.attrElements.keys()) {
      if (!currentAttributes.has(name)) {
        this.removeAttribute(name);
      }
    }

    // Only loop through the current attributes on the node.  Missing
    // attributes have already been removed at this point.
    for (const attr of nodeAttributes) {
      const el = this.attrElements.get(attr.name);
      const valueChanged = el && el.dataset.value !== attr.value;
      const isEditing = el && el.querySelector(".editable").inplaceEditor;
      const canSimplyShowEditor = el && (!valueChanged || isEditing);

      if (canSimplyShowEditor) {
        // Element already exists and doesn't need to be recreated.
        // Just show it (it's hidden by default).
        el.style.removeProperty("display");
      } else {
        // Create a new editor, because the value of an existing attribute
        // has changed.
        const attribute = this._createAttribute(attr, el);
        attribute.style.removeProperty("display");

        // Temporarily flash the attribute to highlight the change.
        // But not if this is the first time the editor instance has
        // been created.
        if (this.initialized) {
          this.flashAttribute(attr.name);
        }
      }
    }

    this.updateEventBadge();
    this.updateDisplayBadge();
    this.updateCustomBadge();
    this.updateScrollableBadge();
    this.updateContainerBadge();
    this.updateTextEditor();
    this.updateUnavailableChildren();
    this.updateOverflowBadge();
    this.updateOverflowHighlight();
  },

  updateEventBadge() {
    const showEventBadge = this.node.hasEventListeners;
    if (this._eventBadge && !showEventBadge) {
      this._eventBadge.remove();
      this._eventBadge = null;
    } else if (showEventBadge && !this._eventBadge) {
      this._createEventBadge();
    }
  },

  _createEventBadge() {
    this._eventBadge = this.doc.createElement("button");
    this._eventBadge.className = "inspector-badge interactive";
    this._eventBadge.dataset.event = "true";
    this._eventBadge.textContent = "event";
    this._eventBadge.title = INSPECTOR_L10N.getStr(
      "markupView.event.tooltiptext2"
    );
    this._eventBadge.setAttribute("aria-pressed", "false");
    // Badges order is [event][display][custom], insert event badge before others.
    this.elt.insertBefore(
      this._eventBadge,
      this._displayBadge || this._customBadge
    );
    this.markup.emit("badge-added-event");
  },

  updateScrollableBadge() {
    if (this.node.isScrollable && !this._scrollableBadge) {
      this._createScrollableBadge();
    } else if (this._scrollableBadge && !this.node.isScrollable) {
      this._scrollableBadge.remove();
      this._scrollableBadge = null;
    }
  },

  _createScrollableBadge() {
    const isInteractive =
      this.isOverflowDebuggingEnabled &&
      // Document elements cannot have interative scrollable badges since retrieval of their
      // overflow causing elements is not supported.
      !this.node.isDocumentElement;

    this._scrollableBadge = this.doc.createElement(
      isInteractive ? "button" : "div"
    );
    this._scrollableBadge.className = `inspector-badge scrollable-badge ${
      isInteractive ? "interactive" : ""
    }`;
    this._scrollableBadge.dataset.scrollable = "true";
    this._scrollableBadge.textContent = INSPECTOR_L10N.getStr(
      "markupView.scrollableBadge.label"
    );
    this._scrollableBadge.title = INSPECTOR_L10N.getStr(
      isInteractive
        ? "markupView.scrollableBadge.interactive.tooltip"
        : "markupView.scrollableBadge.tooltip"
    );

    if (isInteractive) {
      this._scrollableBadge.addEventListener(
        "click",
        this.onScrollableBadgeClick
      );
      this._scrollableBadge.setAttribute("aria-pressed", "false");
    }
    this.elt.insertBefore(this._scrollableBadge, this._customBadge);
  },

  /**
   * Update the markup display badge.
   */
  updateDisplayBadge() {
    const displayType = this.node.displayType;
    const showDisplayBadge = displayType in DISPLAY_TYPES;

    if (this._displayBadge && !showDisplayBadge) {
      this._displayBadge.remove();
      this._displayBadge = null;
    } else if (showDisplayBadge) {
      if (!this._displayBadge) {
        this._createDisplayBadge();
      }

      this._updateDisplayBadgeContent();
    }
  },

  _createDisplayBadge() {
    this._displayBadge = this.doc.createElement("button");
    this._displayBadge.className = "inspector-badge";
    this._displayBadge.addEventListener("click", this.onDisplayBadgeClick);
    // Badges order is [event][display][custom], insert display badge before custom.
    this.elt.insertBefore(this._displayBadge, this._customBadge);
  },

  _updateDisplayBadgeContent() {
    const displayType = this.node.displayType;
    this._displayBadge.textContent = displayType;
    this._displayBadge.dataset.display = displayType;
    this._displayBadge.title = DISPLAY_TYPES[displayType];

    const isFlex = displayType === "flex" || displayType === "inline-flex";
    const isGrid =
      displayType === "grid" ||
      displayType === "inline-grid" ||
      displayType === "subgrid";

    const isInteractive =
      isFlex ||
      (isGrid && this.highlighters.canGridHighlighterToggle(this.node));

    this._displayBadge.classList.toggle("interactive", isInteractive);

    // Since the badge is a <button>, if it's not interactive we need to indicate
    // to screen readers that it shouldn't behave like a button.
    // It's easier to have the badge being a button and "downgrading" it like this,
    // than having it as a div and adding interactivity.
    if (isInteractive) {
      this._displayBadge.removeAttribute("role");
      this._displayBadge.setAttribute("aria-pressed", "false");
    } else {
      this._displayBadge.setAttribute("role", "presentation");
      this._displayBadge.removeAttribute("aria-pressed");
    }
  },

  updateOverflowBadge() {
    if (!this.isOverflowDebuggingEnabled) {
      return;
    }

    if (this.node.causesOverflow && !this._overflowBadge) {
      this._createOverflowBadge();
    } else if (!this.node.causesOverflow && this._overflowBadge) {
      this._overflowBadge.remove();
      this._overflowBadge = null;
    }
  },

  _createOverflowBadge() {
    this._overflowBadge = this.doc.createElement("div");
    this._overflowBadge.className = "inspector-badge overflow-badge";
    this._overflowBadge.textContent = INSPECTOR_L10N.getStr(
      "markupView.overflowBadge.label"
    );
    this._overflowBadge.title = INSPECTOR_L10N.getStr(
      "markupView.overflowBadge.tooltip"
    );
    this.elt.insertBefore(this._overflowBadge, this._customBadge);
  },

  /**
   * Update the markup custom element badge.
   */
  updateCustomBadge() {
    const showCustomBadge = !!this.node.customElementLocation;
    if (this._customBadge && !showCustomBadge) {
      this._customBadge.remove();
      this._customBadge = null;
    } else if (!this._customBadge && showCustomBadge) {
      this._createCustomBadge();
    }
  },

  _createCustomBadge() {
    this._customBadge = this.doc.createElement("button");
    this._customBadge.className = "inspector-badge interactive";
    this._customBadge.dataset.custom = "true";
    this._customBadge.textContent = "custom…";
    this._customBadge.title = INSPECTOR_L10N.getStr(
      "markupView.custom.tooltiptext"
    );
    this._customBadge.addEventListener("click", this.onCustomBadgeClick);
    // Badges order is [event][display][custom], insert custom badge at the end.
    this.elt.appendChild(this._customBadge);
  },

  updateContainerBadge() {
    const showContainerBadge =
      this.node.containerType === "inline-size" ||
      this.node.containerType === "size";

    if (this._containerBadge && !showContainerBadge) {
      this._containerBadge.remove();
      this._containerBadge = null;
    } else if (showContainerBadge && !this._containerBadge) {
      this._createContainerBadge();
    }
  },

  _createContainerBadge() {
    this._containerBadge = this.doc.createElement("div");
    this._containerBadge.classList.add("inspector-badge");
    this._containerBadge.dataset.container = "true";
    this._containerBadge.title = `container-type: ${this.node.containerType}`;

    this._containerBadge.append(this.doc.createTextNode("container"));
    // TODO: Move the logic to handle badges position in a dedicated helper (See Bug 1837921).
    // Ideally badges order should be [event][display][container][custom]
    this.elt.insertBefore(this._containerBadge, this._customBadge);
    this.markup.emit("badge-added-event");
  },

  /**
   * If node causes overflow, toggle its overflow highlight if its scrollable ancestor's
   * scrollable badge is active/inactive.
   */
  async updateOverflowHighlight() {
    if (!this.isOverflowDebuggingEnabled) {
      return;
    }

    let showOverflowHighlight = false;

    if (this.node.causesOverflow) {
      try {
        const scrollableAncestor =
          await this.node.walkerFront.getScrollableAncestorNode(this.node);
        const markupContainer = scrollableAncestor
          ? this.markup.getContainer(scrollableAncestor)
          : null;

        showOverflowHighlight =
          !!markupContainer?.editor.highlightingOverflowCausingElements;
      } catch (e) {
        // This call might fail if called asynchrously after the toolbox is finished
        // closing.
        return;
      }
    }

    this.setOverflowHighlight(showOverflowHighlight);
  },

  /**
   * Show overflow highlight if showOverflowHighlight is true, otherwise hide it.
   *
   * @param {Boolean} showOverflowHighlight
   */
  setOverflowHighlight(showOverflowHighlight) {
    this.container.tagState.classList.toggle(
      "overflow-causing-highlighted",
      showOverflowHighlight
    );
  },

  /**
   * Update the inline text editor in case of a single text child node.
   */
  updateTextEditor() {
    const node = this.node.inlineTextChild;

    if (this.textEditor && this.textEditor.node != node) {
      this.elt.removeChild(this.textEditor.elt);
      this.textEditor.destroy();
      this.textEditor = null;
    }

    if (node && !this.textEditor) {
      // Create a text editor added to this editor.
      // This editor won't receive an update automatically, so we rely on
      // child text editors to let us know that we need updating.
      this.textEditor = new TextEditor(this.container, node, "text");
      this.elt.insertBefore(
        this.textEditor.elt,
        this.elt.querySelector(".close")
      );
    }

    if (this.textEditor) {
      this.textEditor.update();
    }
  },

  hasUnavailableChildren() {
    return !!this.childrenUnavailableElt;
  },

  /**
   * Update a special badge displayed for nodes which have children that can't
   * be inspected by the current session (eg a parent-process only toolbox
   * inspecting a content browser).
   */
  updateUnavailableChildren() {
    const childrenUnavailable = this.node.childrenUnavailable;

    if (this.childrenUnavailableElt) {
      this.elt.removeChild(this.childrenUnavailableElt);
      this.childrenUnavailableElt = null;
    }

    if (childrenUnavailable) {
      this.childrenUnavailableElt = this.doc.createElement("div");
      this.childrenUnavailableElt.className = "unavailable-children";
      this.childrenUnavailableElt.dataset.label = INSPECTOR_L10N.getStr(
        "markupView.unavailableChildren.label"
      );
      this.childrenUnavailableElt.title = INSPECTOR_L10N.getStr(
        "markupView.unavailableChildren.title"
      );
      this.elt.insertBefore(
        this.childrenUnavailableElt,
        this.elt.querySelector(".close")
      );
    }
  },

  _startModifyingAttributes() {
    return this.node.startModifyingAttributes();
  },

  /**
   * Get the element used for one of the attributes of this element.
   *
   * @param  {String} attrName
   *         The name of the attribute to get the element for
   * @return {DOMNode}
   */
  getAttributeElement(attrName) {
    return this.attrList.querySelector(
      ".attreditor[data-attr=" + CSS.escape(attrName) + "] .attr-value"
    );
  },

  /**
   * Remove an attribute from the attrElements object and the DOM.
   *
   * @param  {String} attrName
   *         The name of the attribute to remove
   */
  removeAttribute(attrName) {
    const attr = this.attrElements.get(attrName);
    if (attr) {
      this.attrElements.delete(attrName);
      attr.remove();
    }
  },

  /**
   * Creates and returns the DOM for displaying an attribute with the following DOM
   * structure:
   *
   * dom.span(
   *   {
   *     className: "attreditor",
   *     "data-attr": attribute.name,
   *     "data-value": attribute.value,
   *   },
   *   " ",
   *   dom.span(
   *     { className: "editable", tabIndex: 0 },
   *     dom.span({ className: "attr-name" }, attribute.name),
   *     '="',
   *     dom.span({ className: "attr-value" }, attribute.value),
   *     '"'
   *   )
   */
  _createAttribute(attribute, before = null) {
    const attr = this.doc.createElement("span");
    attr.dataset.attr = attribute.name;
    attr.dataset.value = attribute.value;
    attr.classList.add("attreditor");
    attr.style.display = "none";

    attr.appendChild(this.doc.createTextNode(" "));

    const inner = this.doc.createElement("span");
    inner.classList.add("editable");
    inner.setAttribute("tabindex", this.container.canFocus ? "0" : "-1");
    attr.appendChild(inner);

    const name = this.doc.createElement("span");
    name.classList.add("attr-name", "force-color-on-flash");
    name.textContent = attribute.name;
    inner.appendChild(name);

    inner.appendChild(this.doc.createTextNode('="'));

    const val = this.doc.createElement("span");
    val.classList.add("attr-value", "force-color-on-flash");
    inner.appendChild(val);

    inner.appendChild(this.doc.createTextNode('"'));

    this._setupAttributeEditor(attribute, attr, inner, name, val);

    // Figure out where we should place the attribute.
    if (attribute.name == "id") {
      before = this.attrList.firstChild;
    } else if (attribute.name == "class") {
      const idNode = this.attrElements.get("id");
      before = idNode ? idNode.nextSibling : this.attrList.firstChild;
    }
    this.attrList.insertBefore(attr, before);

    this.removeAttribute(attribute.name);
    this.attrElements.set(attribute.name, attr);

    this._appendAttributeValue(attribute, val);

    return attr;
  },

  /**
   * Setup the editable field for the given attribute.
   *
   * @param  {Object} attribute
   *         An object containing the name and value of a DOM attribute.
   * @param  {Element} attrEditorEl
   *         The attribute container <span class="attreditor"> element.
   * @param  {Element} editableEl
   *         The editable <span class="editable"> element that is setup to be
   *         an editable field.
   * @param  {Element} attrNameEl
   *         The attribute name <span class="attr-name"> element.
   * @param  {Element} attrValueEl
   *         The attribute value <span class="attr-value"> element.
   */
  _setupAttributeEditor(
    attribute,
    attrEditorEl,
    editableEl,
    attrNameEl,
    attrValueEl
  ) {
    // Double quotes need to be handled specially to prevent DOMParser failing.
    // name="v"a"l"u"e" when editing -> name='v"a"l"u"e"'
    // name="v'a"l'u"e" when editing -> name="v'a&quot;l'u&quot;e"
    let editValueDisplayed = attribute.value || "";
    const hasDoubleQuote = editValueDisplayed.includes('"');
    const hasSingleQuote = editValueDisplayed.includes("'");
    let initial = attribute.name + '="' + editValueDisplayed + '"';

    // Can't just wrap value with ' since the value contains both " and '.
    if (hasDoubleQuote && hasSingleQuote) {
      editValueDisplayed = editValueDisplayed.replace(/\"/g, "&quot;");
      initial = attribute.name + '="' + editValueDisplayed + '"';
    }

    // Wrap with ' since there are no single quotes in the attribute value.
    if (hasDoubleQuote && !hasSingleQuote) {
      initial = attribute.name + "='" + editValueDisplayed + "'";
    }

    // Make the attribute editable.
    attrEditorEl.editMode = editableField({
      element: editableEl,
      trigger: "dblclick",
      stopOnReturn: true,
      selectAll: false,
      initial,
      multiline: true,
      maxWidth: () => getAutocompleteMaxWidth(editableEl, this.container.elt),
      contentType: InplaceEditor.CONTENT_TYPES.CSS_MIXED,
      popup: this.markup.popup,
      start: (editor, event) => {
        // If the editing was started inside the name or value areas,
        // select accordingly.
        if (event?.target === attrNameEl) {
          editor.input.setSelectionRange(0, attrNameEl.textContent.length);
        } else if (event?.target.closest(".attr-value") === attrValueEl) {
          const length = editValueDisplayed.length;
          const editorLength = editor.input.value.length;
          const start = editorLength - (length + 1);
          editor.input.setSelectionRange(start, start + length);
        } else {
          editor.input.select();
        }
      },
      done: (newValue, commit, direction) => {
        if (!commit || newValue === initial) {
          return;
        }

        const doMods = this._startModifyingAttributes();
        const undoMods = this._startModifyingAttributes();

        // Remove the attribute stored in this editor and re-add any attributes
        // parsed out of the input element. Restore original attribute if
        // parsing fails.
        this.refocusOnEdit(attribute.name, attrEditorEl, direction);
        this._saveAttribute(attribute.name, undoMods);
        doMods.removeAttribute(attribute.name);
        this._applyAttributes(newValue, attrEditorEl, doMods, undoMods);
        this.container.undo.do(
          () => {
            doMods.apply();
          },
          () => {
            undoMods.apply();
          }
        );
      },
      cssProperties: this._cssProperties,
    });
  },

  /**
   * Appends the attribute value to the given attribute value <span> element.
   *
   * @param  {Object} attribute
   *         An object containing the name and value of a DOM attribute.
   * @param  {Element} attributeValueEl
   *         The attribute value <span class="attr-value"> element to append
   *         the parsed attribute values to.
   */
  _appendAttributeValue(attribute, attributeValueEl) {
    // Parse the attribute value to detect whether there are linkable parts in
    // it (make sure to pass a complete list of existing attributes to the
    // parseAttribute function, by concatenating attribute, because this could
    // be a newly added attribute not yet on this.node).
    const attributes = this.node.attributes.filter(
      existingAttribute => existingAttribute.name !== attribute.name
    );
    attributes.push(attribute);

    const parsedLinksData = parseAttribute(
      this.node.namespaceURI,
      this.node.tagName,
      attributes,
      attribute.name,
      attribute.value
    );

    attributeValueEl.innerHTML = "";

    // Create links in the attribute value, and truncate long attribute values if needed.
    for (const token of parsedLinksData) {
      if (token.type === "string" || token.value?.trim() === "") {
        attributeValueEl.appendChild(
          this.doc.createTextNode(this._truncateAttributeValue(token.value))
        );
      } else {
        const link = this.doc.createElement("span");
        link.classList.add("link");
        link.setAttribute("data-type", token.type);
        link.setAttribute("data-link", token.value);
        link.textContent = this._truncateAttributeValue(token.value);
        attributeValueEl.append(link);

        // Add a "select node" button when we reference element ids
        if (
          token.type === ATTRIBUTE_TYPES.TYPE_IDREF ||
          token.type === ATTRIBUTE_TYPES.TYPE_IDREF_LIST
        ) {
          const button = this.doc.createElement("button");
          button.classList.add("select-node");
          button.setAttribute(
            "title",
            INSPECTOR_L10N.getFormatStr(
              "inspector.menu.selectElement.label",
              token.value
            )
          );
          link.append(button);
        }
      }
    }
  },

  /**
   * Truncates the given attribute value if it is a base64 data URL or the
   * collapse attributes pref is enabled.
   *
   * @param  {String} value
   *         Attribute value.
   * @return {String} truncated attribute value.
   */
  _truncateAttributeValue(value) {
    if (value && value.match(COLLAPSE_DATA_URL_REGEX)) {
      return truncateString(value, COLLAPSE_DATA_URL_LENGTH);
    }

    return this.markup.collapseAttributes
      ? truncateString(value, this.markup.collapseAttributeLength)
      : value;
  },

  /**
   * Parse a user-entered attribute string and apply the resulting
   * attributes to the node. This operation is undoable.
   *
   * @param  {String} value
   *         The user-entered value.
   * @param  {DOMNode} attrNode
   *         The attribute editor that created this
   *         set of attributes, used to place new attributes where the
   *         user put them.
   */
  _applyAttributes(value, attrNode, doMods, undoMods) {
    const attrs = parseAttributeValues(value, this.doc);
    for (const attr of attrs) {
      // Create an attribute editor next to the current attribute if needed.
      this._createAttribute(attr, attrNode ? attrNode.nextSibling : null);
      this._saveAttribute(attr.name, undoMods);
      doMods.setAttribute(attr.name, attr.value);
    }
  },

  /**
   * Saves the current state of the given attribute into an attribute
   * modification list.
   */
  _saveAttribute(name, undoMods) {
    const node = this.node;
    if (node.hasAttribute(name)) {
      const oldValue = node.getAttribute(name);
      undoMods.setAttribute(name, oldValue);
    } else {
      undoMods.removeAttribute(name);
    }
  },

  /**
   * Listen to mutations, and when the attribute list is regenerated
   * try to focus on the attribute after the one that's being edited now.
   * If the attribute order changes, go to the beginning of the attribute list.
   */
  refocusOnEdit(attrName, attrNode, direction) {
    // Only allow one refocus on attribute change at a time, so when there's
    // more than 1 request in parallel, the last one wins.
    if (this._editedAttributeObserver) {
      this.markup.inspector.off(
        "markupmutation",
        this._editedAttributeObserver
      );
      this._editedAttributeObserver = null;
    }

    const activeElement = this.markup.doc.activeElement;
    if (!activeElement || !activeElement.inplaceEditor) {
      // The focus was already removed from the current inplace editor, we should not
      // refocus the editable attribute.
      return;
    }

    const container = this.markup.getContainer(this.node);

    const activeAttrs = [...this.attrList.childNodes].filter(
      el => el.style.display != "none"
    );
    const attributeIndex = activeAttrs.indexOf(attrNode);

    const onMutations = (this._editedAttributeObserver = mutations => {
      let isDeletedAttribute = false;
      let isNewAttribute = false;

      for (const mutation of mutations) {
        const inContainer =
          this.markup.getContainer(mutation.target) === container;
        if (!inContainer) {
          continue;
        }

        const isOriginalAttribute = mutation.attributeName === attrName;

        isDeletedAttribute =
          isDeletedAttribute ||
          (isOriginalAttribute && mutation.newValue === null);
        isNewAttribute = isNewAttribute || mutation.attributeName !== attrName;
      }

      const isModifiedOrder = isDeletedAttribute && isNewAttribute;
      this._editedAttributeObserver = null;

      // "Deleted" attributes are merely hidden, so filter them out.
      const visibleAttrs = [...this.attrList.childNodes].filter(
        el => el.style.display != "none"
      );
      let activeEditor;
      if (visibleAttrs.length) {
        if (!direction) {
          // No direction was given; stay on current attribute.
          activeEditor = visibleAttrs[attributeIndex];
        } else if (isModifiedOrder) {
          // The attribute was renamed, reordering the existing attributes.
          // So let's go to the beginning of the attribute list for consistency.
          activeEditor = visibleAttrs[0];
        } else {
          let newAttributeIndex;
          if (isDeletedAttribute) {
            newAttributeIndex = attributeIndex;
          } else if (direction == Services.focus.MOVEFOCUS_FORWARD) {
            newAttributeIndex = attributeIndex + 1;
          } else if (direction == Services.focus.MOVEFOCUS_BACKWARD) {
            newAttributeIndex = attributeIndex - 1;
          }

          // The number of attributes changed (deleted), or we moved through
          // the array so check we're still within bounds.
          if (
            newAttributeIndex >= 0 &&
            newAttributeIndex <= visibleAttrs.length - 1
          ) {
            activeEditor = visibleAttrs[newAttributeIndex];
          }
        }
      }

      // Either we have no attributes left,
      // or we just edited the last attribute and want to move on.
      if (!activeEditor) {
        activeEditor = this.newAttr;
      }

      // Refocus was triggered by tab or shift-tab.
      // Continue in edit mode.
      if (direction) {
        activeEditor.editMode();
      } else {
        // Refocus was triggered by enter.
        // Exit edit mode (but restore focus).
        const editable =
          activeEditor === this.newAttr
            ? activeEditor
            : activeEditor.querySelector(".editable");
        editable.focus();
      }

      this.markup.emit("refocusedonedit");
    });

    // Start listening for mutations until we find an attributes change
    // that modifies this attribute.
    this.markup.inspector.once("markupmutation", onMutations);
  },

  /**
   * Called when the display badge is clicked. Toggles on the flexbox/grid highlighter for
   * the selected node if it is a grid container.
   *
   * Event handling for highlighter events is delegated up to the Markup view panel.
   * When a flexbox/grid highlighter is shown or hidden, the corresponding badge will
   * be marked accordingly. See MarkupView.handleHighlighterEvent()
   */
  async onDisplayBadgeClick(event) {
    event.stopPropagation();

    const target = event.target;

    if (
      target.dataset.display === "flex" ||
      target.dataset.display === "inline-flex"
    ) {
      await this.highlighters.toggleFlexboxHighlighter(this.node, "markup");
    }

    if (
      target.dataset.display === "grid" ||
      target.dataset.display === "inline-grid" ||
      target.dataset.display === "subgrid"
    ) {
      // Don't toggle the grid highlighter if the max number of new grid highlighters
      // allowed has been reached.
      if (!this.highlighters.canGridHighlighterToggle(this.node)) {
        return;
      }

      await this.highlighters.toggleGridHighlighter(this.node, "markup");
    }
  },

  async onCustomBadgeClick() {
    const { url, line, column } = this.node.customElementLocation;

    this.markup.toolbox.viewSourceInDebugger(
      url,
      line,
      column,
      null,
      "show_custom_element"
    );
  },

  onExpandBadgeClick() {
    this.container.expandContainer();
  },

  /**
   * Called when the scrollable badge is clicked. Shows the overflow causing elements and
   * highlights their container if the scroll badge is active.
   */
  async onScrollableBadgeClick() {
    this.highlightingOverflowCausingElements =
      this._scrollableBadge.classList.toggle("active");
    this._scrollableBadge.setAttribute(
      "aria-pressed",
      this.highlightingOverflowCausingElements
    );

    const { nodes } = await this.node.walkerFront.getOverflowCausingElements(
      this.node
    );

    for (const node of nodes) {
      if (this.highlightingOverflowCausingElements) {
        await this.markup.showNode(node);
      }

      const markupContainer = this.markup.getContainer(node);

      if (markupContainer) {
        markupContainer.editor.setOverflowHighlight(
          this.highlightingOverflowCausingElements
        );
      }
    }

    Glean.devtoolsMarkupScrollableBadge.clicked.add(1);
  },

  /**
   * Called when the tag name editor has is done editing.
   */
  async onTagEdit(inputValue, isCommit) {
    if (!isCommit) {
      return;
    }

    inputValue = inputValue.trim();
    const spaceIndex = inputValue.indexOf(" ");
    const newTagName =
      spaceIndex === -1 ? inputValue : inputValue.substring(0, spaceIndex);

    const shouldUpdateTagName =
      newTagName.toLowerCase() !== this.node.tagName.toLowerCase();

    // If there is content after the tagName, we could have attributes that we need to set
    // Changing the tag name removes the node, so set the attributes first, then they
    // will be copied in `editTagName`
    const newAttributes =
      spaceIndex === -1 ? null : inputValue.substring(spaceIndex + 1).trim();
    if (newAttributes?.length) {
      const doMods = this._startModifyingAttributes();
      const undoMods = this._startModifyingAttributes();
      this._applyAttributes(newAttributes, null, doMods, undoMods);
      // if the tagName will be changed, a new node will be created, and we don't handle
      // undo for this, so we can directly set the attributes.
      if (shouldUpdateTagName) {
        await doMods.apply();
        undoMods.destroy();
      } else {
        this.container.undo.do(
          () => doMods.apply(),
          () => undoMods.apply()
        );
      }
    }

    if (!shouldUpdateTagName) {
      return;
    }

    // Changing the tagName removes the node. Make sure the replacing node gets
    // selected afterwards.
    this.markup.reselectOnRemoved(this.node, "edittagname");
    try {
      await this.node.walkerFront.editTagName(this.node, newTagName);
    } catch (e) {
      // Failed to edit the tag name, cancel the reselection.
      this.markup.cancelReselectOnRemoved();
    }
  },

  destroy() {
    if (this._displayBadge) {
      this._displayBadge.removeEventListener("click", this.onDisplayBadgeClick);
    }

    if (this._customBadge) {
      this._customBadge.removeEventListener("click", this.onCustomBadgeClick);
    }

    if (this._scrollableBadge) {
      this._scrollableBadge.removeEventListener(
        "click",
        this.onScrollableBadgeClick
      );
    }

    this.expandBadge.removeEventListener("click", this.onExpandBadgeClick);

    for (const key in this.animationTimers) {
      clearTimeout(this.animationTimers[key]);
    }
    this.animationTimers = null;
  },
};

module.exports = ElementEditor;
