/* 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 Services = require("Services"); const TextEditor = require("devtools/client/inspector/markup/views/text-editor"); const { getAutocompleteMaxWidth, flashElementOn, flashElementOff, parseAttributeValues, truncateString, } = require("devtools/client/inspector/markup/utils"); const {editableField, InplaceEditor} = require("devtools/client/shared/inplace-editor"); const {parseAttribute} = require("devtools/client/shared/node-attribute-parser"); const {getCssProperties} = require("devtools/shared/fronts/css-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" ]; /** * Creates an editor for an Element node. * * @param {MarkupContainer} container * The container owning this editor. * @param {Element} node * The node being edited. */ function ElementEditor(container, node) { this.container = container; this.node = node; this.markup = this.container.markup; this.template = this.markup.template.bind(this.markup); this.doc = this.markup.doc; this._cssProperties = getCssProperties(this.markup.toolbox); this.attrElements = new Map(); this.animationTimers = {}; // The templates will fill the following properties this.elt = null; this.tag = null; this.closeTag = null; this.attrList = null; this.newAttr = null; this.closeElt = null; // Create the main editor this.template("element", this); // Make the tag name editable (unless this is a remote node or // a document element) if (!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.bind(this), contextMenu: this.markup.inspector.onTextBoxContextMenu, cssProperties: this._cssProperties }); } // 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; } let doMods = this._startModifyingAttributes(); let undoMods = this._startModifyingAttributes(); this._applyAttributes(val, null, doMods, undoMods); this.container.undo.do(() => { doMods.apply(); }, function () { undoMods.apply(); }); }, contextMenu: this.markup.inspector.onTextBoxContextMenu, cssProperties: this._cssProperties }); let displayName = this.node.displayName; this.tag.textContent = displayName; this.closeTag.textContent = displayName; let isVoidElement = HTML_VOID_ELEMENTS.includes(displayName); if (node.isInHTMLDocument && isVoidElement) { this.elt.classList.add("void-element"); } this.update(); this.initialized = true; } ElementEditor.prototype = { set selected(value) { if (this.textEditor) { this.textEditor.selected = value; } }, flashAttribute: function (attrName) { if (this.animationTimers[attrName]) { clearTimeout(this.animationTimers[attrName]); } flashElementOn(this.getAttributeElement(attrName)); this.animationTimers[attrName] = setTimeout(() => { flashElementOff(this.getAttributeElement(attrName)); }, 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: function (node) { if (!node) { return null; } let type = null; let name = null; let value = null; // Attribute let attribute = node.closest(".attreditor"); if (attribute) { type = "attribute"; name = attribute.querySelector(".attr-name").textContent; value = attribute.querySelector(".attr-value").textContent; } return {type, name, value, el: node}; }, /** * Update the state of the editor from the node. */ update: function () { let nodeAttributes = this.node.attributes || []; // Keep the data model in sync with attributes on the node. let currentAttributes = new Set(nodeAttributes.map(a => a.name)); for (let 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 (let attr of nodeAttributes) { let el = this.attrElements.get(attr.name); let valueChanged = el && el.dataset.value !== attr.value; let isEditing = el && el.querySelector(".editable").inplaceEditor; let canSimplyShowEditor = el && (!valueChanged || isEditing); if (canSimplyShowEditor) { // Element already exists and doesn't need to be recreated. // Just show it (it's hidden by default due to the template). el.style.removeProperty("display"); } else { // Create a new editor, because the value of an existing attribute // has changed. let 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); } } } // Update the event bubble display this.eventNode.style.display = this.node.hasEventListeners ? "inline-block" : "none"; this.updateTextEditor(); }, /** * Update the inline text editor in case of a single text child node. */ updateTextEditor: function () { let node = this.node.inlineTextChild; if (this.textEditor && this.textEditor.node != node) { this.elt.removeChild(this.textEditor.elt); 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.firstChild.nextSibling.nextSibling); } if (this.textEditor) { this.textEditor.update(); } }, _startModifyingAttributes: function () { 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: function (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: function (attrName) { let attr = this.attrElements.get(attrName); if (attr) { this.attrElements.delete(attrName); attr.remove(); } }, _createAttribute: function (attribute, before = null) { // Create the template editor, which will save some variables here. let data = { attrName: attribute.name, attrValue: attribute.value, tabindex: this.container.canFocus ? "0" : "-1", }; this.template("attribute", data); let {attr, inner, name, val} = data; // 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 || ""; let hasDoubleQuote = editValueDisplayed.includes('"'); let 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. attr.editMode = editableField({ element: inner, trigger: "dblclick", stopOnReturn: true, selectAll: false, initial: initial, multiline: true, maxWidth: () => getAutocompleteMaxWidth(inner, 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 && event.target === name) { editor.input.setSelectionRange(0, name.textContent.length); } else if (event && event.target.closest(".attr-value") === val) { let length = editValueDisplayed.length; let editorLength = editor.input.value.length; let start = editorLength - (length + 1); editor.input.setSelectionRange(start, start + length); } else { editor.input.select(); } }, done: (newValue, commit, direction) => { if (!commit || newValue === initial) { return; } let doMods = this._startModifyingAttributes(); let 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, attr, direction); this._saveAttribute(attribute.name, undoMods); doMods.removeAttribute(attribute.name); this._applyAttributes(newValue, attr, doMods, undoMods); this.container.undo.do(() => { doMods.apply(); }, () => { undoMods.apply(); }); }, contextMenu: this.markup.inspector.onTextBoxContextMenu, cssProperties: this._cssProperties }); // Figure out where we should place the attribute. if (attribute.name == "id") { before = this.attrList.firstChild; } else if (attribute.name == "class") { let 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); // 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). let attributes = this.node.attributes.filter(existingAttribute => { return existingAttribute.name !== attribute.name; }); attributes.push(attribute); let parsedLinksData = parseAttribute(this.node.namespaceURI, this.node.tagName, attributes, attribute.name); // Create links in the attribute value, and collapse long attributes if // needed. let collapse = 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; }; val.innerHTML = ""; for (let token of parsedLinksData) { if (token.type === "string") { val.appendChild(this.doc.createTextNode(collapse(token.value))); } else { let link = this.doc.createElement("span"); link.classList.add("link"); link.setAttribute("data-type", token.type); link.setAttribute("data-link", token.value); link.textContent = collapse(token.value); val.appendChild(link); } } name.textContent = attribute.name; return attr; }, /** * 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: function (value, attrNode, doMods, undoMods) { let attrs = parseAttributeValues(value, this.doc); for (let 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: function (name, undoMods) { let node = this.node; if (node.hasAttribute(name)) { let 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: function (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; } let container = this.markup.getContainer(this.node); let activeAttrs = [...this.attrList.childNodes] .filter(el => el.style.display != "none"); let attributeIndex = activeAttrs.indexOf(attrNode); let onMutations = this._editedAttributeObserver = (e, mutations) => { let isDeletedAttribute = false; let isNewAttribute = false; for (let mutation of mutations) { let inContainer = this.markup.getContainer(mutation.target) === container; if (!inContainer) { continue; } let isOriginalAttribute = mutation.attributeName === attrName; isDeletedAttribute = isDeletedAttribute || isOriginalAttribute && mutation.newValue === null; isNewAttribute = isNewAttribute || mutation.attributeName !== attrName; } let isModifiedOrder = isDeletedAttribute && isNewAttribute; this._editedAttributeObserver = null; // "Deleted" attributes are merely hidden, so filter them out. let visibleAttrs = [...this.attrList.childNodes] .filter(el => el.style.display != "none"); let activeEditor; if (visibleAttrs.length > 0) { 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). let 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 tag name editor has is done editing. */ onTagEdit: function (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.markup.walker.editTagName(this.node, newTagName).then(null, () => { // Failed to edit the tag name, cancel the reselection. this.markup.cancelReselectOnRemoved(); }); }, destroy: function () { for (let key in this.animationTimers) { clearTimeout(this.animationTimers[key]); } this.animationTimers = null; } }; module.exports = ElementEditor;