summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/markup/views/element-editor.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/markup/views/element-editor.js')
-rw-r--r--devtools/client/inspector/markup/views/element-editor.js560
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;