From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- .../inspector/markup/views/element-container.js | 193 ++++++ .../inspector/markup/views/element-editor.js | 560 ++++++++++++++++ .../client/inspector/markup/views/html-editor.js | 180 ++++++ .../inspector/markup/views/markup-container.js | 720 +++++++++++++++++++++ devtools/client/inspector/markup/views/moz.build | 17 + .../inspector/markup/views/read-only-container.js | 33 + .../inspector/markup/views/read-only-editor.js | 43 ++ .../inspector/markup/views/root-container.js | 55 ++ .../inspector/markup/views/text-container.js | 40 ++ .../client/inspector/markup/views/text-editor.js | 109 ++++ 10 files changed, 1950 insertions(+) create mode 100644 devtools/client/inspector/markup/views/element-container.js create mode 100644 devtools/client/inspector/markup/views/element-editor.js create mode 100644 devtools/client/inspector/markup/views/html-editor.js create mode 100644 devtools/client/inspector/markup/views/markup-container.js create mode 100644 devtools/client/inspector/markup/views/moz.build create mode 100644 devtools/client/inspector/markup/views/read-only-container.js create mode 100644 devtools/client/inspector/markup/views/read-only-editor.js create mode 100644 devtools/client/inspector/markup/views/root-container.js create mode 100644 devtools/client/inspector/markup/views/text-container.js create mode 100644 devtools/client/inspector/markup/views/text-editor.js (limited to 'devtools/client/inspector/markup/views') 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"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; 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