diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/inspector/markup/views/element-editor.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/markup/views/element-editor.js')
-rw-r--r-- | devtools/client/inspector/markup/views/element-editor.js | 1149 |
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"l'u"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, """); + 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; |