summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/markup/views/element-editor.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/inspector/markup/views/element-editor.js1149
1 files changed, 1149 insertions, 0 deletions
diff --git a/devtools/client/inspector/markup/views/element-editor.js b/devtools/client/inspector/markup/views/element-editor.js
new file mode 100644
index 0000000000..f5a97b3b84
--- /dev/null
+++ b/devtools/client/inspector/markup/views/element-editor.js
@@ -0,0 +1,1149 @@
+/* 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,
+} = 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", "theme-fg-color3");
+ 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,
+ 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", "theme-fg-color3");
+ 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.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("div");
+ this._eventBadge.className = "inspector-badge interactive";
+ this._eventBadge.dataset.event = "true";
+ this._eventBadge.textContent = "event";
+ this._eventBadge.title = INSPECTOR_L10N.getStr(
+ "markupView.event.tooltiptext"
+ );
+ // 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("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.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("div");
+ 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);
+ },
+
+ 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("div");
+ 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);
+ },
+
+ /**
+ * 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 theme-fg-color1" }, attribute.name),
+ * '="',
+ * dom.span({ className: "attr-value theme-fg-color2" }, 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");
+ name.classList.add("theme-fg-color1");
+ name.textContent = attribute.name;
+ inner.appendChild(name);
+
+ inner.appendChild(this.doc.createTextNode('="'));
+
+ const val = this.doc.createElement("span");
+ val.classList.add("attr-value");
+ val.classList.add("theme-fg-color2");
+ 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") {
+ 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.appendChild(link);
+ }
+ }
+ },
+
+ /**
+ * 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");
+
+ 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
+ );
+ }
+ }
+
+ this.markup.telemetry.scalarAdd(
+ "devtools.markup.scrollable.badge.clicked",
+ 1
+ );
+ },
+
+ /**
+ * Called when the tag name editor has is done editing.
+ */
+ onTagEdit(newTagName, isCommit) {
+ if (
+ !isCommit ||
+ newTagName.toLowerCase() === this.node.tagName.toLowerCase() ||
+ !("editTagName" in this.markup.walker)
+ ) {
+ return;
+ }
+
+ // Changing the tagName removes the node. Make sure the replacing node gets
+ // selected afterwards.
+ this.markup.reselectOnRemoved(this.node, "edittagname");
+ this.node.walkerFront.editTagName(this.node, newTagName).catch(() => {
+ // 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;