/* 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/string.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("")); }, 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