summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/markup/views
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commit5f8de423f190bbb79a62f804151bc24824fa32d8 (patch)
tree10027f336435511475e392454359edea8e25895d /devtools/client/inspector/markup/views
parent49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff)
downloadUXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz
UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip
Add m-esr52 at 52.6.0
Diffstat (limited to 'devtools/client/inspector/markup/views')
-rw-r--r--devtools/client/inspector/markup/views/element-container.js193
-rw-r--r--devtools/client/inspector/markup/views/element-editor.js560
-rw-r--r--devtools/client/inspector/markup/views/html-editor.js180
-rw-r--r--devtools/client/inspector/markup/views/markup-container.js720
-rw-r--r--devtools/client/inspector/markup/views/moz.build17
-rw-r--r--devtools/client/inspector/markup/views/read-only-container.js33
-rw-r--r--devtools/client/inspector/markup/views/read-only-editor.js43
-rw-r--r--devtools/client/inspector/markup/views/root-container.js55
-rw-r--r--devtools/client/inspector/markup/views/text-container.js40
-rw-r--r--devtools/client/inspector/markup/views/text-editor.js109
10 files changed, 1950 insertions, 0 deletions
diff --git a/devtools/client/inspector/markup/views/element-container.js b/devtools/client/inspector/markup/views/element-container.js
new file mode 100644
index 000000000..851a803cb
--- /dev/null
+++ b/devtools/client/inspector/markup/views/element-container.js
@@ -0,0 +1,193 @@
+/* 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 PREVIEW_MAX_DIM_PREF = "devtools.inspector.imagePreviewTooltipSize";
+
+const promise = require("promise");
+const Services = require("Services");
+const Heritage = require("sdk/core/heritage");
+const {Task} = require("devtools/shared/task");
+const nodeConstants = require("devtools/shared/dom-node-constants");
+const clipboardHelper = require("devtools/shared/platform/clipboard");
+const {setImageTooltip, setBrokenImageTooltip} =
+ require("devtools/client/shared/widgets/tooltip/ImageTooltipHelper");
+const {setEventTooltip} = require("devtools/client/shared/widgets/tooltip/EventTooltipHelper");
+const MarkupContainer = require("devtools/client/inspector/markup/views/markup-container");
+const ElementEditor = require("devtools/client/inspector/markup/views/element-editor");
+
+/**
+ * An implementation of MarkupContainer for Elements that can contain
+ * child nodes.
+ * Allows editing of tag name, attributes, expanding / collapsing.
+ *
+ * @param {MarkupView} markupView
+ * The markup view that owns this container.
+ * @param {NodeFront} node
+ * The node to display.
+ */
+function MarkupElementContainer(markupView, node) {
+ MarkupContainer.prototype.initialize.call(this, markupView, node,
+ "elementcontainer");
+
+ if (node.nodeType === nodeConstants.ELEMENT_NODE) {
+ this.editor = new ElementEditor(this, node);
+ } else {
+ throw new Error("Invalid node for MarkupElementContainer");
+ }
+
+ this.tagLine.appendChild(this.editor.elt);
+}
+
+MarkupElementContainer.prototype = Heritage.extend(MarkupContainer.prototype, {
+ _buildEventTooltipContent: Task.async(function* (target, tooltip) {
+ if (target.hasAttribute("data-event")) {
+ yield tooltip.hide();
+
+ let listenerInfo = yield this.node.getEventListenerInfo();
+
+ let toolbox = this.markup.toolbox;
+ setEventTooltip(tooltip, listenerInfo, toolbox);
+ // Disable the image preview tooltip while we display the event details
+ this.markup._disableImagePreviewTooltip();
+ tooltip.once("hidden", () => {
+ // Enable the image preview tooltip after closing the event details
+ this.markup._enableImagePreviewTooltip();
+ });
+ tooltip.show(target);
+ }
+ }),
+
+ /**
+ * Generates the an image preview for this Element. The element must be an
+ * image or canvas (@see isPreviewable).
+ *
+ * @return {Promise} that is resolved with an object of form
+ * { data, size: { naturalWidth, naturalHeight, resizeRatio } } where
+ * - data is the data-uri for the image preview.
+ * - size contains information about the original image size and if
+ * the preview has been resized.
+ *
+ * If this element is not previewable or the preview cannot be generated for
+ * some reason, the Promise is rejected.
+ */
+ _getPreview: function () {
+ if (!this.isPreviewable()) {
+ return promise.reject("_getPreview called on a non-previewable element.");
+ }
+
+ if (this.tooltipDataPromise) {
+ // A preview request is already pending. Re-use that request.
+ return this.tooltipDataPromise;
+ }
+
+ // Fetch the preview from the server.
+ this.tooltipDataPromise = Task.spawn(function* () {
+ let maxDim = Services.prefs.getIntPref(PREVIEW_MAX_DIM_PREF);
+ let preview = yield this.node.getImageData(maxDim);
+ let data = yield preview.data.string();
+
+ // Clear the pending preview request. We can't reuse the results later as
+ // the preview contents might have changed.
+ this.tooltipDataPromise = null;
+ return { data, size: preview.size };
+ }.bind(this));
+
+ return this.tooltipDataPromise;
+ },
+
+ /**
+ * Executed by MarkupView._isImagePreviewTarget which is itself called when
+ * the mouse hovers over a target in the markup-view.
+ * Checks if the target is indeed something we want to have an image tooltip
+ * preview over and, if so, inserts content into the tooltip.
+ *
+ * @return {Promise} that resolves when the tooltip content is ready. Resolves
+ * true if the tooltip should be displayed, false otherwise.
+ */
+ isImagePreviewTarget: Task.async(function* (target, tooltip) {
+ // Is this Element previewable.
+ if (!this.isPreviewable()) {
+ return false;
+ }
+
+ // If the Element has an src attribute, the tooltip is shown when hovering
+ // over the src url. If not, the tooltip is shown when hovering over the tag
+ // name.
+ let src = this.editor.getAttributeElement("src");
+ let expectedTarget = src ? src.querySelector(".link") : this.editor.tag;
+ if (target !== expectedTarget) {
+ return false;
+ }
+
+ try {
+ let { data, size } = yield this._getPreview();
+ // The preview is ready.
+ let options = {
+ naturalWidth: size.naturalWidth,
+ naturalHeight: size.naturalHeight,
+ maxDim: Services.prefs.getIntPref(PREVIEW_MAX_DIM_PREF)
+ };
+
+ setImageTooltip(tooltip, this.markup.doc, data, options);
+ } catch (e) {
+ // Indicate the failure but show the tooltip anyway.
+ setBrokenImageTooltip(tooltip, this.markup.doc);
+ }
+ return true;
+ }),
+
+ copyImageDataUri: function () {
+ // We need to send again a request to gettooltipData even if one was sent
+ // for the tooltip, because we want the full-size image
+ this.node.getImageData().then(data => {
+ data.data.string().then(str => {
+ clipboardHelper.copyString(str);
+ });
+ });
+ },
+
+ setInlineTextChild: function (inlineTextChild) {
+ this.inlineTextChild = inlineTextChild;
+ this.editor.updateTextEditor();
+ },
+
+ clearInlineTextChild: function () {
+ this.inlineTextChild = undefined;
+ this.editor.updateTextEditor();
+ },
+
+ /**
+ * Trigger new attribute field for input.
+ */
+ addAttribute: function () {
+ this.editor.newAttr.editMode();
+ },
+
+ /**
+ * Trigger attribute field for editing.
+ */
+ editAttribute: function (attrName) {
+ this.editor.attrElements.get(attrName).editMode();
+ },
+
+ /**
+ * Remove attribute from container.
+ * This is an undoable action.
+ */
+ removeAttribute: function (attrName) {
+ let doMods = this.editor._startModifyingAttributes();
+ let undoMods = this.editor._startModifyingAttributes();
+ this.editor._saveAttribute(attrName, undoMods);
+ doMods.removeAttribute(attrName);
+ this.undo.do(() => {
+ doMods.apply();
+ }, () => {
+ undoMods.apply();
+ });
+ }
+});
+
+module.exports = MarkupElementContainer;
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&quot;l'u&quot;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, "&quot;");
+ 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;
diff --git a/devtools/client/inspector/markup/views/html-editor.js b/devtools/client/inspector/markup/views/html-editor.js
new file mode 100644
index 000000000..6f99391b6
--- /dev/null
+++ b/devtools/client/inspector/markup/views/html-editor.js
@@ -0,0 +1,180 @@
+/* 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 Editor = require("devtools/client/sourceeditor/editor");
+const Services = require("Services");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+/**
+ * A wrapper around the Editor component, that allows editing of HTML.
+ *
+ * The main functionality this provides around the Editor is the ability
+ * to show/hide/position an editor inplace. It only appends once to the
+ * body, and uses CSS to position the editor. The reason it is done this
+ * way is that the editor is loaded in an iframe, and calling appendChild
+ * causes it to reload.
+ *
+ * Meant to be embedded inside of an HTML page, as in markup.xhtml.
+ *
+ * @param {HTMLDocument} htmlDocument
+ * The document to attach the editor to. Will also use this
+ * document as a basis for listening resize events.
+ */
+function HTMLEditor(htmlDocument) {
+ this.doc = htmlDocument;
+ this.container = this.doc.createElement("div");
+ this.container.className = "html-editor theme-body";
+ this.container.style.display = "none";
+ this.editorInner = this.doc.createElement("div");
+ this.editorInner.className = "html-editor-inner";
+ this.container.appendChild(this.editorInner);
+
+ this.doc.body.appendChild(this.container);
+ this.hide = this.hide.bind(this);
+ this.refresh = this.refresh.bind(this);
+
+ EventEmitter.decorate(this);
+
+ this.doc.defaultView.addEventListener("resize",
+ this.refresh, true);
+
+ let config = {
+ mode: Editor.modes.html,
+ lineWrapping: true,
+ styleActiveLine: false,
+ extraKeys: {},
+ theme: "mozilla markup-view"
+ };
+
+ config.extraKeys[ctrl("Enter")] = this.hide;
+ config.extraKeys.F2 = this.hide;
+ config.extraKeys.Esc = this.hide.bind(this, false);
+
+ this.container.addEventListener("click", this.hide, false);
+ this.editorInner.addEventListener("click", stopPropagation, false);
+ this.editor = new Editor(config);
+
+ this.editor.appendToLocalElement(this.editorInner);
+ this.hide(false);
+}
+
+HTMLEditor.prototype = {
+
+ /**
+ * Need to refresh position by manually setting CSS values, so this will
+ * need to be called on resizes and other sizing changes.
+ */
+ refresh: function () {
+ let element = this._attachedElement;
+
+ if (element) {
+ this.container.style.top = element.offsetTop + "px";
+ this.container.style.left = element.offsetLeft + "px";
+ this.container.style.width = element.offsetWidth + "px";
+ this.container.style.height = element.parentNode.offsetHeight + "px";
+ this.editor.refresh();
+ }
+ },
+
+ /**
+ * Anchor the editor to a particular element.
+ *
+ * @param {DOMNode} element
+ * The element that the editor will be anchored to.
+ * Should belong to the HTMLDocument passed into the constructor.
+ */
+ _attach: function (element) {
+ this._detach();
+ this._attachedElement = element;
+ element.classList.add("html-editor-container");
+ this.refresh();
+ },
+
+ /**
+ * Unanchor the editor from an element.
+ */
+ _detach: function () {
+ if (this._attachedElement) {
+ this._attachedElement.classList.remove("html-editor-container");
+ this._attachedElement = undefined;
+ }
+ },
+
+ /**
+ * Anchor the editor to a particular element, and show the editor.
+ *
+ * @param {DOMNode} element
+ * The element that the editor will be anchored to.
+ * Should belong to the HTMLDocument passed into the constructor.
+ * @param {String} text
+ * Value to set the contents of the editor to
+ * @param {Function} cb
+ * The function to call when hiding
+ */
+ show: function (element, text) {
+ if (this._visible) {
+ return;
+ }
+
+ this._originalValue = text;
+ this.editor.setText(text);
+ this._attach(element);
+ this.container.style.display = "flex";
+ this._visible = true;
+
+ this.editor.refresh();
+ this.editor.focus();
+
+ this.emit("popupshown");
+ },
+
+ /**
+ * Hide the editor, optionally committing the changes
+ *
+ * @param {Boolean} shouldCommit
+ * A change will be committed by default. If this param
+ * strictly equals false, no change will occur.
+ */
+ hide: function (shouldCommit) {
+ if (!this._visible) {
+ return;
+ }
+
+ this.container.style.display = "none";
+ this._detach();
+
+ let newValue = this.editor.getText();
+ let valueHasChanged = this._originalValue !== newValue;
+ let preventCommit = shouldCommit === false || !valueHasChanged;
+ this._originalValue = undefined;
+ this._visible = undefined;
+ this.emit("popuphidden", !preventCommit, newValue);
+ },
+
+ /**
+ * Destroy this object and unbind all event handlers
+ */
+ destroy: function () {
+ this.doc.defaultView.removeEventListener("resize",
+ this.refresh, true);
+ this.container.removeEventListener("click", this.hide, false);
+ this.editorInner.removeEventListener("click", stopPropagation, false);
+
+ this.hide(false);
+ this.container.remove();
+ this.editor.destroy();
+ }
+};
+
+function ctrl(k) {
+ return (Services.appinfo.OS == "Darwin" ? "Cmd-" : "Ctrl-") + k;
+}
+
+function stopPropagation(e) {
+ e.stopPropagation();
+}
+
+module.exports = HTMLEditor;
diff --git a/devtools/client/inspector/markup/views/markup-container.js b/devtools/client/inspector/markup/views/markup-container.js
new file mode 100644
index 000000000..b54157242
--- /dev/null
+++ b/devtools/client/inspector/markup/views/markup-container.js
@@ -0,0 +1,720 @@
+/* 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 {Task} = require("devtools/shared/task");
+const {KeyCodes} = require("devtools/client/shared/keycodes");
+const {flashElementOn, flashElementOff} =
+ require("devtools/client/inspector/markup/utils");
+
+const DRAG_DROP_MIN_INITIAL_DISTANCE = 10;
+
+/**
+ * The main structure for storing a document node in the markup
+ * tree. Manages creation of the editor for the node and
+ * a <ul> for placing child elements, and expansion/collapsing
+ * of the element.
+ *
+ * This should not be instantiated directly, instead use one of:
+ * MarkupReadOnlyContainer
+ * MarkupTextContainer
+ * MarkupElementContainer
+ */
+function MarkupContainer() { }
+
+/**
+ * Unique identifier used to set markup container node id.
+ * @type {Number}
+ */
+let markupContainerID = 0;
+
+MarkupContainer.prototype = {
+ /*
+ * Initialize the MarkupContainer. Should be called while one
+ * of the other contain classes is instantiated.
+ *
+ * @param {MarkupView} markupView
+ * The markup view that owns this container.
+ * @param {NodeFront} node
+ * The node to display.
+ * @param {String} templateID
+ * Which template to render for this container
+ */
+ initialize: function (markupView, node, templateID) {
+ this.markup = markupView;
+ this.node = node;
+ this.undo = this.markup.undo;
+ this.win = this.markup._frame.contentWindow;
+ this.id = "treeitem-" + markupContainerID++;
+ this.htmlElt = this.win.document.documentElement;
+
+ // The template will fill the following properties
+ this.elt = null;
+ this.expander = null;
+ this.tagState = null;
+ this.tagLine = null;
+ this.children = null;
+ this.markup.template(templateID, this);
+ this.elt.container = this;
+
+ this._onMouseDown = this._onMouseDown.bind(this);
+ this._onToggle = this._onToggle.bind(this);
+ this._onMouseUp = this._onMouseUp.bind(this);
+ this._onMouseMove = this._onMouseMove.bind(this);
+ this._onKeyDown = this._onKeyDown.bind(this);
+
+ // Binding event listeners
+ this.elt.addEventListener("mousedown", this._onMouseDown, false);
+ this.win.addEventListener("mouseup", this._onMouseUp, true);
+ this.win.addEventListener("mousemove", this._onMouseMove, true);
+ this.elt.addEventListener("dblclick", this._onToggle, false);
+ if (this.expander) {
+ this.expander.addEventListener("click", this._onToggle, false);
+ }
+
+ // Marking the node as shown or hidden
+ this.updateIsDisplayed();
+ },
+
+ toString: function () {
+ return "[MarkupContainer for " + this.node + "]";
+ },
+
+ isPreviewable: function () {
+ if (this.node.tagName && !this.node.isPseudoElement) {
+ let tagName = this.node.tagName.toLowerCase();
+ let srcAttr = this.editor.getAttributeElement("src");
+ let isImage = tagName === "img" && srcAttr;
+ let isCanvas = tagName === "canvas";
+
+ return isImage || isCanvas;
+ }
+
+ return false;
+ },
+
+ /**
+ * Show whether the element is displayed or not
+ * If an element has the attribute `display: none` or has been hidden with
+ * the H key, it is not displayed (faded in markup view).
+ * Otherwise, it is displayed.
+ */
+ updateIsDisplayed: function () {
+ this.elt.classList.remove("not-displayed");
+ if (!this.node.isDisplayed || this.node.hidden) {
+ this.elt.classList.add("not-displayed");
+ }
+ },
+
+ /**
+ * True if the current node has children. The MarkupView
+ * will set this attribute for the MarkupContainer.
+ */
+ _hasChildren: false,
+
+ get hasChildren() {
+ return this._hasChildren;
+ },
+
+ set hasChildren(value) {
+ this._hasChildren = value;
+ this.updateExpander();
+ },
+
+ /**
+ * A list of all elements with tabindex that are not in container's children.
+ */
+ get focusableElms() {
+ return [...this.tagLine.querySelectorAll("[tabindex]")];
+ },
+
+ /**
+ * An indicator that the container internals are focusable.
+ */
+ get canFocus() {
+ return this._canFocus;
+ },
+
+ /**
+ * Toggle focusable state for container internals.
+ */
+ set canFocus(value) {
+ if (this._canFocus === value) {
+ return;
+ }
+
+ this._canFocus = value;
+
+ if (value) {
+ this.tagLine.addEventListener("keydown", this._onKeyDown, true);
+ this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "0"));
+ } else {
+ this.tagLine.removeEventListener("keydown", this._onKeyDown, true);
+ // Exclude from tab order.
+ this.focusableElms.forEach(elm => elm.setAttribute("tabindex", "-1"));
+ }
+ },
+
+ /**
+ * If conatiner and its contents are focusable, exclude them from tab order,
+ * and, if necessary, remove focus.
+ */
+ clearFocus: function () {
+ if (!this.canFocus) {
+ return;
+ }
+
+ this.canFocus = false;
+ let doc = this.markup.doc;
+
+ if (!doc.activeElement || doc.activeElement === doc.body) {
+ return;
+ }
+
+ let parent = doc.activeElement;
+
+ while (parent && parent !== this.elt) {
+ parent = parent.parentNode;
+ }
+
+ if (parent) {
+ doc.activeElement.blur();
+ }
+ },
+
+ /**
+ * True if the current node can be expanded.
+ */
+ get canExpand() {
+ return this._hasChildren && !this.node.inlineTextChild;
+ },
+
+ /**
+ * True if this is the root <html> element and can't be collapsed.
+ */
+ get mustExpand() {
+ return this.node._parent === this.markup.walker.rootNode;
+ },
+
+ /**
+ * True if current node can be expanded and collapsed.
+ */
+ get showExpander() {
+ return this.canExpand && !this.mustExpand;
+ },
+
+ updateExpander: function () {
+ if (!this.expander) {
+ return;
+ }
+
+ if (this.showExpander) {
+ this.expander.style.visibility = "visible";
+ // Update accessibility expanded state.
+ this.tagLine.setAttribute("aria-expanded", this.expanded);
+ } else {
+ this.expander.style.visibility = "hidden";
+ // No need for accessible expanded state indicator when expander is not
+ // shown.
+ this.tagLine.removeAttribute("aria-expanded");
+ }
+ },
+
+ /**
+ * If current node has no children, ignore them. Otherwise, consider them a
+ * group from the accessibility point of view.
+ */
+ setChildrenRole: function () {
+ this.children.setAttribute("role",
+ this.hasChildren ? "group" : "presentation");
+ },
+
+ /**
+ * Set an appropriate DOM tree depth level for a node and its subtree.
+ */
+ updateLevel: function () {
+ // ARIA level should already be set when container template is rendered.
+ let currentLevel = this.tagLine.getAttribute("aria-level");
+ let newLevel = this.level;
+ if (currentLevel === newLevel) {
+ // If level did not change, ignore this node and its subtree.
+ return;
+ }
+
+ this.tagLine.setAttribute("aria-level", newLevel);
+ let childContainers = this.getChildContainers();
+ if (childContainers) {
+ childContainers.forEach(container => container.updateLevel());
+ }
+ },
+
+ /**
+ * If the node has children, return the list of containers for all these
+ * children.
+ */
+ getChildContainers: function () {
+ if (!this.hasChildren) {
+ return null;
+ }
+
+ return [...this.children.children].filter(node => node.container)
+ .map(node => node.container);
+ },
+
+ /**
+ * True if the node has been visually expanded in the tree.
+ */
+ get expanded() {
+ return !this.elt.classList.contains("collapsed");
+ },
+
+ setExpanded: function (value) {
+ if (!this.expander) {
+ return;
+ }
+
+ if (!this.canExpand) {
+ value = false;
+ }
+ if (this.mustExpand) {
+ value = true;
+ }
+
+ if (value && this.elt.classList.contains("collapsed")) {
+ // Expanding a node means cloning its "inline" closing tag into a new
+ // tag-line that the user can interact with and showing the children.
+ let closingTag = this.elt.querySelector(".close");
+ if (closingTag) {
+ if (!this.closeTagLine) {
+ let line = this.markup.doc.createElement("div");
+ line.classList.add("tag-line");
+ // Closing tag is not important for accessibility.
+ line.setAttribute("role", "presentation");
+
+ let tagState = this.markup.doc.createElement("div");
+ tagState.classList.add("tag-state");
+ line.appendChild(tagState);
+
+ line.appendChild(closingTag.cloneNode(true));
+
+ flashElementOff(line);
+ this.closeTagLine = line;
+ }
+ this.elt.appendChild(this.closeTagLine);
+ }
+
+ this.elt.classList.remove("collapsed");
+ this.expander.setAttribute("open", "");
+ this.hovered = false;
+ this.markup.emit("expanded");
+ } else if (!value) {
+ if (this.closeTagLine) {
+ this.elt.removeChild(this.closeTagLine);
+ this.closeTagLine = undefined;
+ }
+ this.elt.classList.add("collapsed");
+ this.expander.removeAttribute("open");
+ this.markup.emit("collapsed");
+ }
+ if (this.showExpander) {
+ this.tagLine.setAttribute("aria-expanded", this.expanded);
+ }
+ },
+
+ parentContainer: function () {
+ return this.elt.parentNode ? this.elt.parentNode.container : null;
+ },
+
+ /**
+ * Determine tree depth level of a given node. This is used to specify ARIA
+ * level for node tree items and to give them better semantic context.
+ */
+ get level() {
+ let level = 1;
+ let parent = this.node.parentNode();
+ while (parent && parent !== this.markup.walker.rootNode) {
+ level++;
+ parent = parent.parentNode();
+ }
+ return level;
+ },
+
+ _isDragging: false,
+ _dragStartY: 0,
+
+ set isDragging(isDragging) {
+ let rootElt = this.markup.getContainer(this.markup._rootNode).elt;
+ this._isDragging = isDragging;
+ this.markup.isDragging = isDragging;
+ this.tagLine.setAttribute("aria-grabbed", isDragging);
+
+ if (isDragging) {
+ this.htmlElt.classList.add("dragging");
+ this.elt.classList.add("dragging");
+ this.markup.doc.body.classList.add("dragging");
+ rootElt.setAttribute("aria-dropeffect", "move");
+ } else {
+ this.htmlElt.classList.remove("dragging");
+ this.elt.classList.remove("dragging");
+ this.markup.doc.body.classList.remove("dragging");
+ rootElt.setAttribute("aria-dropeffect", "none");
+ }
+ },
+
+ get isDragging() {
+ return this._isDragging;
+ },
+
+ /**
+ * Check if element is draggable.
+ */
+ isDraggable: function () {
+ let tagName = this.node.tagName && this.node.tagName.toLowerCase();
+
+ return !this.node.isPseudoElement &&
+ !this.node.isAnonymous &&
+ !this.node.isDocumentElement &&
+ tagName !== "body" &&
+ tagName !== "head" &&
+ this.win.getSelection().isCollapsed &&
+ this.node.parentNode().tagName !== null;
+ },
+
+ /**
+ * Move keyboard focus to a next/previous focusable element inside container
+ * that is not part of its children (only if current focus is on first or last
+ * element).
+ *
+ * @param {DOMNode} current currently focused element
+ * @param {Boolean} back direction
+ * @return {DOMNode} newly focused element if any
+ */
+ _wrapMoveFocus: function (current, back) {
+ let elms = this.focusableElms;
+ let next;
+ if (back) {
+ if (elms.indexOf(current) === 0) {
+ next = elms[elms.length - 1];
+ next.focus();
+ }
+ } else if (elms.indexOf(current) === elms.length - 1) {
+ next = elms[0];
+ next.focus();
+ }
+ return next;
+ },
+
+ _onKeyDown: function (event) {
+ let {target, keyCode, shiftKey} = event;
+ let isInput = this.markup._isInputOrTextarea(target);
+
+ // Ignore all keystrokes that originated in editors except for when 'Tab' is
+ // pressed.
+ if (isInput && keyCode !== KeyCodes.DOM_VK_TAB) {
+ return;
+ }
+
+ switch (keyCode) {
+ case KeyCodes.DOM_VK_TAB:
+ // Only handle 'Tab' if tabbable element is on the edge (first or last).
+ if (isInput) {
+ // Corresponding tabbable element is editor's next sibling.
+ let next = this._wrapMoveFocus(target.nextSibling, shiftKey);
+ if (next) {
+ event.preventDefault();
+ // Keep the editing state if possible.
+ if (next._editable) {
+ let e = this.markup.doc.createEvent("Event");
+ e.initEvent(next._trigger, true, true);
+ next.dispatchEvent(e);
+ }
+ }
+ } else {
+ let next = this._wrapMoveFocus(target, shiftKey);
+ if (next) {
+ event.preventDefault();
+ }
+ }
+ break;
+ case KeyCodes.DOM_VK_ESCAPE:
+ this.clearFocus();
+ this.markup.getContainer(this.markup._rootNode).elt.focus();
+ if (this.isDragging) {
+ // Escape when dragging is handled by markup view itself.
+ return;
+ }
+ event.preventDefault();
+ break;
+ default:
+ return;
+ }
+ event.stopPropagation();
+ },
+
+ _onMouseDown: function (event) {
+ let {target, button, metaKey, ctrlKey} = event;
+ let isLeftClick = button === 0;
+ let isMiddleClick = button === 1;
+ let isMetaClick = isLeftClick && (metaKey || ctrlKey);
+
+ // The "show more nodes" button already has its onclick, so early return.
+ if (target.nodeName === "button") {
+ return;
+ }
+
+ // target is the MarkupContainer itself.
+ this.hovered = false;
+ this.markup.navigate(this);
+ // Make container tabbable descendants tabbable and focus in.
+ this.canFocus = true;
+ this.focus();
+ event.stopPropagation();
+
+ // Preventing the default behavior will avoid the body to gain focus on
+ // mouseup (through bubbling) when clicking on a non focusable node in the
+ // line. So, if the click happened outside of a focusable element, do
+ // prevent the default behavior, so that the tagname or textcontent gains
+ // focus.
+ if (!target.closest(".editor [tabindex]")) {
+ event.preventDefault();
+ }
+
+ // Follow attribute links if middle or meta click.
+ if (isMiddleClick || isMetaClick) {
+ let link = target.dataset.link;
+ let type = target.dataset.type;
+ // Make container tabbable descendants not tabbable (by default).
+ this.canFocus = false;
+ this.markup.inspector.followAttributeLink(type, link);
+ return;
+ }
+
+ // Start node drag & drop (if the mouse moved, see _onMouseMove).
+ if (isLeftClick && this.isDraggable()) {
+ this._isPreDragging = true;
+ this._dragStartY = event.pageY;
+ }
+ },
+
+ /**
+ * On mouse up, stop dragging.
+ */
+ _onMouseUp: Task.async(function* () {
+ this._isPreDragging = false;
+
+ if (this.isDragging) {
+ this.cancelDragging();
+
+ let dropTargetNodes = this.markup.dropTargetNodes;
+
+ if (!dropTargetNodes) {
+ return;
+ }
+
+ yield this.markup.walker.insertBefore(this.node, dropTargetNodes.parent,
+ dropTargetNodes.nextSibling);
+ this.markup.emit("drop-completed");
+ }
+ }),
+
+ /**
+ * On mouse move, move the dragged element and indicate the drop target.
+ */
+ _onMouseMove: function (event) {
+ // If this is the first move after mousedown, only start dragging after the
+ // mouse has travelled a few pixels and then indicate the start position.
+ let initialDiff = Math.abs(event.pageY - this._dragStartY);
+ if (this._isPreDragging && initialDiff >= DRAG_DROP_MIN_INITIAL_DISTANCE) {
+ this._isPreDragging = false;
+ this.isDragging = true;
+
+ // If this is the last child, use the closing <div.tag-line> of parent as
+ // indicator.
+ let position = this.elt.nextElementSibling ||
+ this.markup.getContainer(this.node.parentNode())
+ .closeTagLine;
+ this.markup.indicateDragTarget(position);
+ }
+
+ if (this.isDragging) {
+ let x = 0;
+ let y = event.pageY - this.win.scrollY;
+
+ // Ensure we keep the dragged element within the markup view.
+ if (y < 0) {
+ y = 0;
+ } else if (y >= this.markup.doc.body.offsetHeight - this.win.scrollY) {
+ y = this.markup.doc.body.offsetHeight - this.win.scrollY - 1;
+ }
+
+ let diff = y - this._dragStartY + this.win.scrollY;
+ this.elt.style.top = diff + "px";
+
+ let el = this.markup.doc.elementFromPoint(x, y);
+ this.markup.indicateDropTarget(el);
+ }
+ },
+
+ cancelDragging: function () {
+ if (!this.isDragging) {
+ return;
+ }
+
+ this._isPreDragging = false;
+ this.isDragging = false;
+ this.elt.style.removeProperty("top");
+ },
+
+ /**
+ * Temporarily flash the container to attract attention.
+ * Used for markup mutations.
+ */
+ flashMutation: function () {
+ if (!this.selected) {
+ flashElementOn(this.tagState, this.editor.elt);
+ if (this._flashMutationTimer) {
+ clearTimeout(this._flashMutationTimer);
+ this._flashMutationTimer = null;
+ }
+ this._flashMutationTimer = setTimeout(() => {
+ flashElementOff(this.tagState, this.editor.elt);
+ }, this.markup.CONTAINER_FLASHING_DURATION);
+ }
+ },
+
+ _hovered: false,
+
+ /**
+ * Highlight the currently hovered tag + its closing tag if necessary
+ * (that is if the tag is expanded)
+ */
+ set hovered(value) {
+ this.tagState.classList.remove("flash-out");
+ this._hovered = value;
+ if (value) {
+ if (!this.selected) {
+ this.tagState.classList.add("theme-bg-darker");
+ }
+ if (this.closeTagLine) {
+ this.closeTagLine.querySelector(".tag-state").classList.add(
+ "theme-bg-darker");
+ }
+ } else {
+ this.tagState.classList.remove("theme-bg-darker");
+ if (this.closeTagLine) {
+ this.closeTagLine.querySelector(".tag-state").classList.remove(
+ "theme-bg-darker");
+ }
+ }
+ },
+
+ /**
+ * True if the container is visible in the markup tree.
+ */
+ get visible() {
+ return this.elt.getBoundingClientRect().height > 0;
+ },
+
+ /**
+ * True if the container is currently selected.
+ */
+ _selected: false,
+
+ get selected() {
+ return this._selected;
+ },
+
+ set selected(value) {
+ this.tagState.classList.remove("flash-out");
+ this._selected = value;
+ this.editor.selected = value;
+ // Markup tree item should have accessible selected state.
+ this.tagLine.setAttribute("aria-selected", value);
+ if (this._selected) {
+ let container = this.markup.getContainer(this.markup._rootNode);
+ if (container) {
+ container.elt.setAttribute("aria-activedescendant", this.id);
+ }
+ this.tagLine.setAttribute("selected", "");
+ this.tagState.classList.add("theme-selected");
+ } else {
+ this.tagLine.removeAttribute("selected");
+ this.tagState.classList.remove("theme-selected");
+ }
+ },
+
+ /**
+ * Update the container's editor to the current state of the
+ * viewed node.
+ */
+ update: function () {
+ if (this.node.pseudoClassLocks.length) {
+ this.elt.classList.add("pseudoclass-locked");
+ } else {
+ this.elt.classList.remove("pseudoclass-locked");
+ }
+
+ if (this.editor.update) {
+ this.editor.update();
+ }
+ },
+
+ /**
+ * Try to put keyboard focus on the current editor.
+ */
+ focus: function () {
+ // Elements with tabindex of -1 are not focusable.
+ let focusable = this.editor.elt.querySelector("[tabindex='0']");
+ if (focusable) {
+ focusable.focus();
+ }
+ },
+
+ _onToggle: function (event) {
+ this.markup.navigate(this);
+ if (this.hasChildren) {
+ this.markup.setNodeExpanded(this.node, !this.expanded, event.altKey);
+ }
+ event.stopPropagation();
+ },
+
+ /**
+ * Get rid of event listeners and references, when the container is no longer
+ * needed
+ */
+ destroy: function () {
+ // Remove event listeners
+ this.elt.removeEventListener("mousedown", this._onMouseDown, false);
+ this.elt.removeEventListener("dblclick", this._onToggle, false);
+ this.tagLine.removeEventListener("keydown", this._onKeyDown, true);
+ if (this.win) {
+ this.win.removeEventListener("mouseup", this._onMouseUp, true);
+ this.win.removeEventListener("mousemove", this._onMouseMove, true);
+ }
+
+ this.win = null;
+ this.htmlElt = null;
+
+ if (this.expander) {
+ this.expander.removeEventListener("click", this._onToggle, false);
+ }
+
+ // Recursively destroy children containers
+ let firstChild = this.children.firstChild;
+ while (firstChild) {
+ // Not all children of a container are containers themselves
+ // ("show more nodes" button is one example)
+ if (firstChild.container) {
+ firstChild.container.destroy();
+ }
+ this.children.removeChild(firstChild);
+ firstChild = this.children.firstChild;
+ }
+
+ this.editor.destroy();
+ }
+};
+
+module.exports = MarkupContainer;
diff --git a/devtools/client/inspector/markup/views/moz.build b/devtools/client/inspector/markup/views/moz.build
new file mode 100644
index 000000000..846bc6a84
--- /dev/null
+++ b/devtools/client/inspector/markup/views/moz.build
@@ -0,0 +1,17 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'element-container.js',
+ 'element-editor.js',
+ 'html-editor.js',
+ 'markup-container.js',
+ 'read-only-container.js',
+ 'read-only-editor.js',
+ 'root-container.js',
+ 'text-container.js',
+ 'text-editor.js',
+)
diff --git a/devtools/client/inspector/markup/views/read-only-container.js b/devtools/client/inspector/markup/views/read-only-container.js
new file mode 100644
index 000000000..fd645baac
--- /dev/null
+++ b/devtools/client/inspector/markup/views/read-only-container.js
@@ -0,0 +1,33 @@
+/* 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 Heritage = require("sdk/core/heritage");
+const ReadOnlyEditor = require("devtools/client/inspector/markup/views/read-only-editor");
+const MarkupContainer = require("devtools/client/inspector/markup/views/markup-container");
+
+/**
+ * An implementation of MarkupContainer for Pseudo Elements,
+ * Doctype nodes, or any other type generic node that doesn't
+ * fit for other editors.
+ * Does not allow any editing, just viewing / selecting.
+ *
+ * @param {MarkupView} markupView
+ * The markup view that owns this container.
+ * @param {NodeFront} node
+ * The node to display.
+ */
+function MarkupReadOnlyContainer(markupView, node) {
+ MarkupContainer.prototype.initialize.call(this, markupView, node,
+ "readonlycontainer");
+
+ this.editor = new ReadOnlyEditor(this, node);
+ this.tagLine.appendChild(this.editor.elt);
+}
+
+MarkupReadOnlyContainer.prototype =
+ Heritage.extend(MarkupContainer.prototype, {});
+
+module.exports = MarkupReadOnlyContainer;
diff --git a/devtools/client/inspector/markup/views/read-only-editor.js b/devtools/client/inspector/markup/views/read-only-editor.js
new file mode 100644
index 000000000..dbc39eeb7
--- /dev/null
+++ b/devtools/client/inspector/markup/views/read-only-editor.js
@@ -0,0 +1,43 @@
+/* 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 nodeConstants = require("devtools/shared/dom-node-constants");
+
+/**
+ * Creates an editor for non-editable nodes.
+ */
+function ReadOnlyEditor(container, node) {
+ this.container = container;
+ this.markup = this.container.markup;
+ this.template = this.markup.template.bind(this.markup);
+ this.elt = null;
+ this.template("generic", this);
+
+ if (node.isPseudoElement) {
+ this.tag.classList.add("theme-fg-color5");
+ this.tag.textContent = node.isBeforePseudoElement ? "::before" : "::after";
+ } else if (node.nodeType == nodeConstants.DOCUMENT_TYPE_NODE) {
+ this.elt.classList.add("comment");
+ this.tag.textContent = node.doctypeString;
+ } else {
+ this.tag.textContent = node.nodeName;
+ }
+}
+
+ReadOnlyEditor.prototype = {
+ destroy: function () {
+ this.elt.remove();
+ },
+
+ /**
+ * Stub method for consistency with ElementEditor.
+ */
+ getInfoAtNode: function () {
+ return null;
+ }
+};
+
+module.exports = ReadOnlyEditor;
diff --git a/devtools/client/inspector/markup/views/root-container.js b/devtools/client/inspector/markup/views/root-container.js
new file mode 100644
index 000000000..ccc918fca
--- /dev/null
+++ b/devtools/client/inspector/markup/views/root-container.js
@@ -0,0 +1,55 @@
+/* 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";
+
+/**
+ * Dummy container node used for the root document element.
+ */
+function RootContainer(markupView, node) {
+ this.doc = markupView.doc;
+ this.elt = this.doc.createElement("ul");
+ // Root container has tree semantics for accessibility.
+ this.elt.setAttribute("role", "tree");
+ this.elt.setAttribute("tabindex", "0");
+ this.elt.setAttribute("aria-dropeffect", "none");
+ this.elt.container = this;
+ this.children = this.elt;
+ this.node = node;
+ this.toString = () => "[root container]";
+}
+
+RootContainer.prototype = {
+ hasChildren: true,
+ expanded: true,
+ update: function () {},
+ destroy: function () {},
+
+ /**
+ * If the node has children, return the list of containers for all these children.
+ * @return {Array} An array of child containers or null.
+ */
+ getChildContainers: function () {
+ return [...this.children.children].filter(node => node.container)
+ .map(node => node.container);
+ },
+
+ /**
+ * Set the expanded state of the container node.
+ * @param {Boolean} value
+ */
+ setExpanded: function () {},
+
+ /**
+ * Set an appropriate role of the container's children node.
+ */
+ setChildrenRole: function () {},
+
+ /**
+ * Set an appropriate DOM tree depth level for a node and its subtree.
+ */
+ updateLevel: function () {}
+};
+
+module.exports = RootContainer;
diff --git a/devtools/client/inspector/markup/views/text-container.js b/devtools/client/inspector/markup/views/text-container.js
new file mode 100644
index 000000000..357f17778
--- /dev/null
+++ b/devtools/client/inspector/markup/views/text-container.js
@@ -0,0 +1,40 @@
+/* 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 Heritage = require("sdk/core/heritage");
+const nodeConstants = require("devtools/shared/dom-node-constants");
+const TextEditor = require("devtools/client/inspector/markup/views/text-editor");
+const MarkupContainer = require("devtools/client/inspector/markup/views/markup-container");
+
+/**
+ * An implementation of MarkupContainer for text node and comment nodes.
+ * Allows basic text editing in a textarea.
+ *
+ * @param {MarkupView} markupView
+ * The markup view that owns this container.
+ * @param {NodeFront} node
+ * The node to display.
+ * @param {Inspector} inspector
+ * The inspector tool container the markup-view
+ */
+function MarkupTextContainer(markupView, node) {
+ MarkupContainer.prototype.initialize.call(this, markupView, node,
+ "textcontainer");
+
+ if (node.nodeType == nodeConstants.TEXT_NODE) {
+ this.editor = new TextEditor(this, node, "text");
+ } else if (node.nodeType == nodeConstants.COMMENT_NODE) {
+ this.editor = new TextEditor(this, node, "comment");
+ } else {
+ throw new Error("Invalid node for MarkupTextContainer");
+ }
+
+ this.tagLine.appendChild(this.editor.elt);
+}
+
+MarkupTextContainer.prototype = Heritage.extend(MarkupContainer.prototype, {});
+
+module.exports = MarkupTextContainer;
diff --git a/devtools/client/inspector/markup/views/text-editor.js b/devtools/client/inspector/markup/views/text-editor.js
new file mode 100644
index 000000000..f3c83ca87
--- /dev/null
+++ b/devtools/client/inspector/markup/views/text-editor.js
@@ -0,0 +1,109 @@
+/* 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 {getAutocompleteMaxWidth} = require("devtools/client/inspector/markup/utils");
+const {editableField} = require("devtools/client/shared/inplace-editor");
+const {getCssProperties} = require("devtools/shared/fronts/css-properties");
+const {LocalizationHelper} = require("devtools/shared/l10n");
+
+const INSPECTOR_L10N =
+ new LocalizationHelper("devtools/client/locales/inspector.properties");
+
+/**
+ * Creates a simple text editor node, used for TEXT and COMMENT
+ * nodes.
+ *
+ * @param {MarkupContainer} container
+ * The container owning this editor.
+ * @param {DOMNode} node
+ * The node being edited.
+ * @param {String} templateId
+ * The template id to use to build the editor.
+ */
+function TextEditor(container, node, templateId) {
+ this.container = container;
+ this.markup = this.container.markup;
+ this.node = node;
+ this.template = this.markup.template.bind(templateId);
+ this._selected = false;
+
+ this.markup.template(templateId, this);
+
+ editableField({
+ element: this.value,
+ stopOnReturn: true,
+ trigger: "dblclick",
+ multiline: true,
+ maxWidth: () => getAutocompleteMaxWidth(this.value, this.container.elt),
+ trimOutput: false,
+ done: (val, commit) => {
+ if (!commit) {
+ return;
+ }
+ this.node.getNodeValue().then(longstr => {
+ longstr.string().then(oldValue => {
+ longstr.release().then(null, console.error);
+
+ this.container.undo.do(() => {
+ this.node.setNodeValue(val);
+ }, () => {
+ this.node.setNodeValue(oldValue);
+ });
+ });
+ });
+ },
+ cssProperties: getCssProperties(this.markup.toolbox),
+ contextMenu: this.markup.inspector.onTextBoxContextMenu
+ });
+
+ this.update();
+}
+
+TextEditor.prototype = {
+ get selected() {
+ return this._selected;
+ },
+
+ set selected(value) {
+ if (value === this._selected) {
+ return;
+ }
+ this._selected = value;
+ this.update();
+ },
+
+ update: function () {
+ let longstr = null;
+ this.node.getNodeValue().then(ret => {
+ longstr = ret;
+ return longstr.string();
+ }).then(str => {
+ longstr.release().then(null, console.error);
+ this.value.textContent = str;
+
+ let isWhitespace = !/[^\s]/.exec(str);
+ this.value.classList.toggle("whitespace", isWhitespace);
+
+ let chars = str.replace(/\n/g, "⏎")
+ .replace(/\t/g, "⇥")
+ .replace(/ /g, "◦");
+ this.value.setAttribute("title", isWhitespace
+ ? INSPECTOR_L10N.getFormatStr("markupView.whitespaceOnly", chars)
+ : "");
+ }).then(null, console.error);
+ },
+
+ destroy: function () {},
+
+ /**
+ * Stub method for consistency with ElementEditor.
+ */
+ getInfoAtNode: function () {
+ return null;
+ }
+};
+
+module.exports = TextEditor;