diff options
Diffstat (limited to 'devtools/client/inspector/markup/views/element-editor.js')
-rw-r--r-- | devtools/client/inspector/markup/views/element-editor.js | 560 |
1 files changed, 560 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 000000000..3149086eb --- /dev/null +++ b/devtools/client/inspector/markup/views/element-editor.js @@ -0,0 +1,560 @@ +/* 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; |