diff options
Diffstat (limited to 'devtools/client/inspector/markup/views')
12 files changed, 2974 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 0000000000..7aee2bd990 --- /dev/null +++ b/devtools/client/inspector/markup/views/element-container.js @@ -0,0 +1,257 @@ +/* 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 MarkupContainer = require("resource://devtools/client/inspector/markup/views/markup-container.js"); +const ElementEditor = require("resource://devtools/client/inspector/markup/views/element-editor.js"); +const { + ELEMENT_NODE, +} = require("resource://devtools/shared/dom-node-constants.js"); +const { extend } = require("resource://devtools/shared/extend.js"); + +loader.lazyRequireGetter( + this, + "EventTooltip", + "resource://devtools/client/shared/widgets/tooltip/EventTooltipHelper.js", + true +); +loader.lazyRequireGetter( + this, + ["setImageTooltip", "setBrokenImageTooltip"], + "resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js", + true +); +loader.lazyRequireGetter( + this, + "clipboardHelper", + "resource://devtools/shared/platform/clipboard.js" +); + +const PREVIEW_MAX_DIM_PREF = "devtools.inspector.imagePreviewTooltipSize"; + +/** + * 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 === ELEMENT_NODE) { + this.editor = new ElementEditor(this, node); + } else { + throw new Error("Invalid node for MarkupElementContainer"); + } + + this.tagLine.appendChild(this.editor.elt); +} + +MarkupElementContainer.prototype = extend(MarkupContainer.prototype, { + onContainerClick(event) { + if (!event.target.hasAttribute("data-event")) { + return; + } + + this._buildEventTooltipContent(event.target); + }, + + async _buildEventTooltipContent(target) { + const tooltip = this.markup.eventDetailsTooltip; + await tooltip.hide(); + + const listenerInfo = await this.node.getEventListenerInfo(); + + const toolbox = this.markup.toolbox; + + // Create the EventTooltip which will populate the tooltip content. + const eventTooltip = new EventTooltip( + tooltip, + listenerInfo, + toolbox, + this.node + ); + + // Add specific styling to the "event" badge when at least one event is disabled. + // The eventTooltip will take care of clearing the event listener when it's destroyed. + eventTooltip.on( + "event-tooltip-listener-toggled", + ({ hasDisabledEventListeners }) => { + const className = "has-disabled-events"; + if (hasDisabledEventListeners) { + this.editor._eventBadge.classList.add(className); + } else { + this.editor._eventBadge.classList.remove(className); + } + } + ); + + // Disable the image preview tooltip while we display the event details + this.markup._disableImagePreviewTooltip(); + tooltip.once("hidden", () => { + eventTooltip.destroy(); + + // Enable the image preview tooltip after closing the event details + this.markup._enableImagePreviewTooltip(); + + // Allow clicks on the event badge to display the event popup again + // (but allow the currently queued click event to run first). + this.markup.win.setTimeout(() => { + if (this.editor._eventBadge) { + this.editor._eventBadge.style.pointerEvents = "auto"; + } + }, 0); + }); + + // Prevent clicks on the event badge to display the event popup again. + if (this.editor._eventBadge) { + this.editor._eventBadge.style.pointerEvents = "none"; + } + 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() { + 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 = async function () { + const maxDim = Services.prefs.getIntPref(PREVIEW_MAX_DIM_PREF); + const preview = await this.node.getImageData(maxDim); + const data = await 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. + */ + async isImagePreviewTarget(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. + const src = this.editor.getAttributeElement("src"); + const expectedTarget = src ? src.querySelector(".link") : this.editor.tag; + if (target !== expectedTarget) { + return false; + } + + try { + const { data, size } = await this._getPreview(); + // The preview is ready. + const 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() { + // 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(inlineTextChild) { + this.inlineTextChild = inlineTextChild; + this.editor.updateTextEditor(); + }, + + clearInlineTextChild() { + this.inlineTextChild = undefined; + this.editor.updateTextEditor(); + }, + + /** + * Trigger new attribute field for input. + */ + addAttribute() { + this.editor.newAttr.editMode(); + }, + + /** + * Trigger attribute field for editing. + */ + editAttribute(attrName) { + this.editor.attrElements.get(attrName).editMode(); + }, + + /** + * Remove attribute from container. + * This is an undoable action. + */ + removeAttribute(attrName) { + const doMods = this.editor._startModifyingAttributes(); + const 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 0000000000..f5a97b3b84 --- /dev/null +++ b/devtools/client/inspector/markup/views/element-editor.js @@ -0,0 +1,1149 @@ +/* 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 TextEditor = require("resource://devtools/client/inspector/markup/views/text-editor.js"); +const { + truncateString, +} = require("resource://devtools/shared/inspector/utils.js"); +const { + editableField, + InplaceEditor, +} = require("resource://devtools/client/shared/inplace-editor.js"); +const { + parseAttribute, +} = require("resource://devtools/client/shared/node-attribute-parser.js"); + +loader.lazyRequireGetter( + this, + [ + "flashElementOn", + "flashElementOff", + "getAutocompleteMaxWidth", + "parseAttributeValues", + ], + "resource://devtools/client/inspector/markup/utils.js", + true +); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const INSPECTOR_L10N = new LocalizationHelper( + "devtools/client/locales/inspector.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", +]; + +// Contains only valid computed display property types of the node to display in the +// element markup and their respective title tooltip text. +const DISPLAY_TYPES = { + flex: INSPECTOR_L10N.getStr("markupView.display.flex.tooltiptext2"), + "inline-flex": INSPECTOR_L10N.getStr( + "markupView.display.inlineFlex.tooltiptext2" + ), + grid: INSPECTOR_L10N.getStr("markupView.display.grid.tooltiptext2"), + "inline-grid": INSPECTOR_L10N.getStr( + "markupView.display.inlineGrid.tooltiptext2" + ), + subgrid: INSPECTOR_L10N.getStr("markupView.display.subgrid.tooltiptiptext"), + "flow-root": INSPECTOR_L10N.getStr("markupView.display.flowRoot.tooltiptext"), + contents: INSPECTOR_L10N.getStr("markupView.display.contents.tooltiptext2"), +}; + +/** + * Creates an editor for an Element node. + * + * @param {MarkupContainer} container + * The container owning this editor. + * @param {NodeFront} node + * The NodeFront being edited. + */ +function ElementEditor(container, node) { + this.container = container; + this.node = node; + this.markup = this.container.markup; + this.doc = this.markup.doc; + this.inspector = this.markup.inspector; + this.highlighters = this.markup.highlighters; + this._cssProperties = this.inspector.cssProperties; + + this.isOverflowDebuggingEnabled = Services.prefs.getBoolPref( + "devtools.overflow.debugging.enabled" + ); + + // If this is a scrollable element, this specifies whether or not its overflow causing + // elements are highlighted. Otherwise, it is null if the element is not scrollable. + this.highlightingOverflowCausingElements = this.node.isScrollable + ? false + : null; + + this.attrElements = new Map(); + this.animationTimers = {}; + + this.elt = null; + this.tag = null; + this.closeTag = null; + this.attrList = null; + this.newAttr = null; + this.closeElt = null; + + this.onCustomBadgeClick = this.onCustomBadgeClick.bind(this); + this.onDisplayBadgeClick = this.onDisplayBadgeClick.bind(this); + this.onScrollableBadgeClick = this.onScrollableBadgeClick.bind(this); + this.onExpandBadgeClick = this.onExpandBadgeClick.bind(this); + this.onTagEdit = this.onTagEdit.bind(this); + + this.buildMarkup(); + + const isVoidElement = HTML_VOID_ELEMENTS.includes(this.node.displayName); + if (node.isInHTMLDocument && isVoidElement) { + this.elt.classList.add("void-element"); + } + + this.update(); + this.initialized = true; +} + +ElementEditor.prototype = { + buildMarkup() { + this.elt = this.doc.createElement("span"); + this.elt.classList.add("editor"); + + this.renderOpenTag(); + this.renderEventBadge(); + this.renderCloseTag(); + + // Make the tag name editable (unless this is a remote node or + // a document element) + if (!this.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, + cssProperties: this._cssProperties, + }); + } + }, + + renderOpenTag() { + const open = this.doc.createElement("span"); + open.classList.add("open"); + open.appendChild(this.doc.createTextNode("<")); + this.elt.appendChild(open); + + this.tag = this.doc.createElement("span"); + this.tag.classList.add("tag", "theme-fg-color3"); + this.tag.setAttribute("tabindex", "-1"); + this.tag.textContent = this.node.displayName; + open.appendChild(this.tag); + + this.renderAttributes(open); + this.renderNewAttributeEditor(open); + + const closingBracket = this.doc.createElement("span"); + closingBracket.classList.add("closing-bracket"); + closingBracket.textContent = ">"; + open.appendChild(closingBracket); + }, + + renderAttributes(containerEl) { + this.attrList = this.doc.createElement("span"); + containerEl.appendChild(this.attrList); + }, + + renderNewAttributeEditor(containerEl) { + this.newAttr = this.doc.createElement("span"); + this.newAttr.classList.add("newattr"); + this.newAttr.setAttribute("tabindex", "-1"); + this.newAttr.setAttribute( + "aria-label", + INSPECTOR_L10N.getStr("markupView.newAttribute.label") + ); + containerEl.appendChild(this.newAttr); + + // 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; + } + + const doMods = this._startModifyingAttributes(); + const undoMods = this._startModifyingAttributes(); + this._applyAttributes(val, null, doMods, undoMods); + this.container.undo.do( + () => { + doMods.apply(); + }, + function () { + undoMods.apply(); + } + ); + }, + cssProperties: this._cssProperties, + }); + }, + + renderEventBadge() { + this.expandBadge = this.doc.createElement("span"); + this.expandBadge.classList.add("markup-expand-badge"); + this.expandBadge.addEventListener("click", this.onExpandBadgeClick); + this.elt.appendChild(this.expandBadge); + }, + + renderCloseTag() { + const close = this.doc.createElement("span"); + close.classList.add("close"); + close.appendChild(this.doc.createTextNode("</")); + this.elt.appendChild(close); + + this.closeTag = this.doc.createElement("span"); + this.closeTag.classList.add("tag", "theme-fg-color3"); + this.closeTag.textContent = this.node.displayName; + close.appendChild(this.closeTag); + + close.appendChild(this.doc.createTextNode(">")); + }, + + get displayBadge() { + return this._displayBadge; + }, + + set selected(value) { + if (this.textEditor) { + this.textEditor.selected = value; + } + }, + + flashAttribute(attrName) { + if (this.animationTimers[attrName]) { + clearTimeout(this.animationTimers[attrName]); + } + + flashElementOn(this.getAttributeElement(attrName), { + backgroundClass: "theme-bg-contrast", + }); + + this.animationTimers[attrName] = setTimeout(() => { + flashElementOff(this.getAttributeElement(attrName), { + backgroundClass: "theme-bg-contrast", + }); + }, 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(node) { + if (!node) { + return null; + } + + let type = null; + let name = null; + let value = null; + + // Attribute + const attribute = node.closest(".attreditor"); + if (attribute) { + type = "attribute"; + name = attribute.dataset.attr; + value = attribute.dataset.value; + } + + return { type, name, value, el: node }; + }, + + /** + * Update the state of the editor from the node. + */ + update() { + const nodeAttributes = this.node.attributes || []; + + // Keep the data model in sync with attributes on the node. + const currentAttributes = new Set(nodeAttributes.map(a => a.name)); + for (const 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 (const attr of nodeAttributes) { + const el = this.attrElements.get(attr.name); + const valueChanged = el && el.dataset.value !== attr.value; + const isEditing = el && el.querySelector(".editable").inplaceEditor; + const canSimplyShowEditor = el && (!valueChanged || isEditing); + + if (canSimplyShowEditor) { + // Element already exists and doesn't need to be recreated. + // Just show it (it's hidden by default). + el.style.removeProperty("display"); + } else { + // Create a new editor, because the value of an existing attribute + // has changed. + const 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); + } + } + } + + this.updateEventBadge(); + this.updateDisplayBadge(); + this.updateCustomBadge(); + this.updateScrollableBadge(); + this.updateTextEditor(); + this.updateUnavailableChildren(); + this.updateOverflowBadge(); + this.updateOverflowHighlight(); + }, + + updateEventBadge() { + const showEventBadge = this.node.hasEventListeners; + if (this._eventBadge && !showEventBadge) { + this._eventBadge.remove(); + this._eventBadge = null; + } else if (showEventBadge && !this._eventBadge) { + this._createEventBadge(); + } + }, + + _createEventBadge() { + this._eventBadge = this.doc.createElement("div"); + this._eventBadge.className = "inspector-badge interactive"; + this._eventBadge.dataset.event = "true"; + this._eventBadge.textContent = "event"; + this._eventBadge.title = INSPECTOR_L10N.getStr( + "markupView.event.tooltiptext" + ); + // Badges order is [event][display][custom], insert event badge before others. + this.elt.insertBefore( + this._eventBadge, + this._displayBadge || this._customBadge + ); + this.markup.emit("badge-added-event"); + }, + + updateScrollableBadge() { + if (this.node.isScrollable && !this._scrollableBadge) { + this._createScrollableBadge(); + } else if (this._scrollableBadge && !this.node.isScrollable) { + this._scrollableBadge.remove(); + this._scrollableBadge = null; + } + }, + + _createScrollableBadge() { + const isInteractive = + this.isOverflowDebuggingEnabled && + // Document elements cannot have interative scrollable badges since retrieval of their + // overflow causing elements is not supported. + !this.node.isDocumentElement; + + this._scrollableBadge = this.doc.createElement("div"); + this._scrollableBadge.className = `inspector-badge scrollable-badge ${ + isInteractive ? "interactive" : "" + }`; + this._scrollableBadge.dataset.scrollable = "true"; + this._scrollableBadge.textContent = INSPECTOR_L10N.getStr( + "markupView.scrollableBadge.label" + ); + this._scrollableBadge.title = INSPECTOR_L10N.getStr( + isInteractive + ? "markupView.scrollableBadge.interactive.tooltip" + : "markupView.scrollableBadge.tooltip" + ); + + if (isInteractive) { + this._scrollableBadge.addEventListener( + "click", + this.onScrollableBadgeClick + ); + } + this.elt.insertBefore(this._scrollableBadge, this._customBadge); + }, + + /** + * Update the markup display badge. + */ + updateDisplayBadge() { + const displayType = this.node.displayType; + const showDisplayBadge = displayType in DISPLAY_TYPES; + + if (this._displayBadge && !showDisplayBadge) { + this._displayBadge.remove(); + this._displayBadge = null; + } else if (showDisplayBadge) { + if (!this._displayBadge) { + this._createDisplayBadge(); + } + + this._updateDisplayBadgeContent(); + } + }, + + _createDisplayBadge() { + this._displayBadge = this.doc.createElement("div"); + this._displayBadge.className = "inspector-badge"; + this._displayBadge.addEventListener("click", this.onDisplayBadgeClick); + // Badges order is [event][display][custom], insert display badge before custom. + this.elt.insertBefore(this._displayBadge, this._customBadge); + }, + + _updateDisplayBadgeContent() { + const displayType = this.node.displayType; + this._displayBadge.textContent = displayType; + this._displayBadge.dataset.display = displayType; + this._displayBadge.title = DISPLAY_TYPES[displayType]; + + const isFlex = displayType === "flex" || displayType === "inline-flex"; + const isGrid = + displayType === "grid" || + displayType === "inline-grid" || + displayType === "subgrid"; + + const isInteractive = + isFlex || + (isGrid && this.highlighters.canGridHighlighterToggle(this.node)); + + this._displayBadge.classList.toggle("interactive", isInteractive); + }, + + updateOverflowBadge() { + if (!this.isOverflowDebuggingEnabled) { + return; + } + + if (this.node.causesOverflow && !this._overflowBadge) { + this._createOverflowBadge(); + } else if (!this.node.causesOverflow && this._overflowBadge) { + this._overflowBadge.remove(); + this._overflowBadge = null; + } + }, + + _createOverflowBadge() { + this._overflowBadge = this.doc.createElement("div"); + this._overflowBadge.className = "inspector-badge overflow-badge"; + this._overflowBadge.textContent = INSPECTOR_L10N.getStr( + "markupView.overflowBadge.label" + ); + this._overflowBadge.title = INSPECTOR_L10N.getStr( + "markupView.overflowBadge.tooltip" + ); + this.elt.insertBefore(this._overflowBadge, this._customBadge); + }, + + /** + * Update the markup custom element badge. + */ + updateCustomBadge() { + const showCustomBadge = !!this.node.customElementLocation; + if (this._customBadge && !showCustomBadge) { + this._customBadge.remove(); + this._customBadge = null; + } else if (!this._customBadge && showCustomBadge) { + this._createCustomBadge(); + } + }, + + _createCustomBadge() { + this._customBadge = this.doc.createElement("div"); + this._customBadge.className = "inspector-badge interactive"; + this._customBadge.dataset.custom = "true"; + this._customBadge.textContent = "custom…"; + this._customBadge.title = INSPECTOR_L10N.getStr( + "markupView.custom.tooltiptext" + ); + this._customBadge.addEventListener("click", this.onCustomBadgeClick); + // Badges order is [event][display][custom], insert custom badge at the end. + this.elt.appendChild(this._customBadge); + }, + + /** + * If node causes overflow, toggle its overflow highlight if its scrollable ancestor's + * scrollable badge is active/inactive. + */ + async updateOverflowHighlight() { + if (!this.isOverflowDebuggingEnabled) { + return; + } + + let showOverflowHighlight = false; + + if (this.node.causesOverflow) { + try { + const scrollableAncestor = + await this.node.walkerFront.getScrollableAncestorNode(this.node); + const markupContainer = scrollableAncestor + ? this.markup.getContainer(scrollableAncestor) + : null; + + showOverflowHighlight = + !!markupContainer?.editor.highlightingOverflowCausingElements; + } catch (e) { + // This call might fail if called asynchrously after the toolbox is finished + // closing. + return; + } + } + + this.setOverflowHighlight(showOverflowHighlight); + }, + + /** + * Show overflow highlight if showOverflowHighlight is true, otherwise hide it. + * + * @param {Boolean} showOverflowHighlight + */ + setOverflowHighlight(showOverflowHighlight) { + this.container.tagState.classList.toggle( + "overflow-causing-highlighted", + showOverflowHighlight + ); + }, + + /** + * Update the inline text editor in case of a single text child node. + */ + updateTextEditor() { + const node = this.node.inlineTextChild; + + if (this.textEditor && this.textEditor.node != node) { + this.elt.removeChild(this.textEditor.elt); + this.textEditor.destroy(); + 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.querySelector(".close") + ); + } + + if (this.textEditor) { + this.textEditor.update(); + } + }, + + hasUnavailableChildren() { + return !!this.childrenUnavailableElt; + }, + + /** + * Update a special badge displayed for nodes which have children that can't + * be inspected by the current session (eg a parent-process only toolbox + * inspecting a content browser). + */ + updateUnavailableChildren() { + const childrenUnavailable = this.node.childrenUnavailable; + + if (this.childrenUnavailableElt) { + this.elt.removeChild(this.childrenUnavailableElt); + this.childrenUnavailableElt = null; + } + + if (childrenUnavailable) { + this.childrenUnavailableElt = this.doc.createElement("div"); + this.childrenUnavailableElt.className = "unavailable-children"; + this.childrenUnavailableElt.dataset.label = INSPECTOR_L10N.getStr( + "markupView.unavailableChildren.label" + ); + this.childrenUnavailableElt.title = INSPECTOR_L10N.getStr( + "markupView.unavailableChildren.title" + ); + this.elt.insertBefore( + this.childrenUnavailableElt, + this.elt.querySelector(".close") + ); + } + }, + + _startModifyingAttributes() { + 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(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(attrName) { + const attr = this.attrElements.get(attrName); + if (attr) { + this.attrElements.delete(attrName); + attr.remove(); + } + }, + + /** + * Creates and returns the DOM for displaying an attribute with the following DOM + * structure: + * + * dom.span( + * { + * className: "attreditor", + * "data-attr": attribute.name, + * "data-value": attribute.value, + * }, + * " ", + * dom.span( + * { className: "editable", tabIndex: 0 }, + * dom.span({ className: "attr-name theme-fg-color1" }, attribute.name), + * '="', + * dom.span({ className: "attr-value theme-fg-color2" }, attribute.value), + * '"' + * ) + */ + _createAttribute(attribute, before = null) { + const attr = this.doc.createElement("span"); + attr.dataset.attr = attribute.name; + attr.dataset.value = attribute.value; + attr.classList.add("attreditor"); + attr.style.display = "none"; + + attr.appendChild(this.doc.createTextNode(" ")); + + const inner = this.doc.createElement("span"); + inner.classList.add("editable"); + inner.setAttribute("tabindex", this.container.canFocus ? "0" : "-1"); + attr.appendChild(inner); + + const name = this.doc.createElement("span"); + name.classList.add("attr-name"); + name.classList.add("theme-fg-color1"); + name.textContent = attribute.name; + inner.appendChild(name); + + inner.appendChild(this.doc.createTextNode('="')); + + const val = this.doc.createElement("span"); + val.classList.add("attr-value"); + val.classList.add("theme-fg-color2"); + inner.appendChild(val); + + inner.appendChild(this.doc.createTextNode('"')); + + this._setupAttributeEditor(attribute, attr, inner, name, val); + + // Figure out where we should place the attribute. + if (attribute.name == "id") { + before = this.attrList.firstChild; + } else if (attribute.name == "class") { + const 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); + + this._appendAttributeValue(attribute, val); + + return attr; + }, + + /** + * Setup the editable field for the given attribute. + * + * @param {Object} attribute + * An object containing the name and value of a DOM attribute. + * @param {Element} attrEditorEl + * The attribute container <span class="attreditor"> element. + * @param {Element} editableEl + * The editable <span class="editable"> element that is setup to be + * an editable field. + * @param {Element} attrNameEl + * The attribute name <span class="attr-name"> element. + * @param {Element} attrValueEl + * The attribute value <span class="attr-value"> element. + */ + _setupAttributeEditor( + attribute, + attrEditorEl, + editableEl, + attrNameEl, + attrValueEl + ) { + // 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 || ""; + const hasDoubleQuote = editValueDisplayed.includes('"'); + const 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. + attrEditorEl.editMode = editableField({ + element: editableEl, + trigger: "dblclick", + stopOnReturn: true, + selectAll: false, + initial, + multiline: true, + maxWidth: () => getAutocompleteMaxWidth(editableEl, 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?.target === attrNameEl) { + editor.input.setSelectionRange(0, attrNameEl.textContent.length); + } else if (event?.target.closest(".attr-value") === attrValueEl) { + const length = editValueDisplayed.length; + const editorLength = editor.input.value.length; + const start = editorLength - (length + 1); + editor.input.setSelectionRange(start, start + length); + } else { + editor.input.select(); + } + }, + done: (newValue, commit, direction) => { + if (!commit || newValue === initial) { + return; + } + + const doMods = this._startModifyingAttributes(); + const 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, attrEditorEl, direction); + this._saveAttribute(attribute.name, undoMods); + doMods.removeAttribute(attribute.name); + this._applyAttributes(newValue, attrEditorEl, doMods, undoMods); + this.container.undo.do( + () => { + doMods.apply(); + }, + () => { + undoMods.apply(); + } + ); + }, + cssProperties: this._cssProperties, + }); + }, + + /** + * Appends the attribute value to the given attribute value <span> element. + * + * @param {Object} attribute + * An object containing the name and value of a DOM attribute. + * @param {Element} attributeValueEl + * The attribute value <span class="attr-value"> element to append + * the parsed attribute values to. + */ + _appendAttributeValue(attribute, attributeValueEl) { + // 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). + const attributes = this.node.attributes.filter( + existingAttribute => existingAttribute.name !== attribute.name + ); + attributes.push(attribute); + + const parsedLinksData = parseAttribute( + this.node.namespaceURI, + this.node.tagName, + attributes, + attribute.name, + attribute.value + ); + + attributeValueEl.innerHTML = ""; + + // Create links in the attribute value, and truncate long attribute values if + // needed. + for (const token of parsedLinksData) { + if (token.type === "string") { + attributeValueEl.appendChild( + this.doc.createTextNode(this._truncateAttributeValue(token.value)) + ); + } else { + const link = this.doc.createElement("span"); + link.classList.add("link"); + link.setAttribute("data-type", token.type); + link.setAttribute("data-link", token.value); + link.textContent = this._truncateAttributeValue(token.value); + attributeValueEl.appendChild(link); + } + } + }, + + /** + * Truncates the given attribute value if it is a base64 data URL or the + * collapse attributes pref is enabled. + * + * @param {String} value + * Attribute value. + * @return {String} truncated attribute value. + */ + _truncateAttributeValue(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; + }, + + /** + * 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(value, attrNode, doMods, undoMods) { + const attrs = parseAttributeValues(value, this.doc); + for (const 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(name, undoMods) { + const node = this.node; + if (node.hasAttribute(name)) { + const 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(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; + } + + const activeElement = this.markup.doc.activeElement; + if (!activeElement || !activeElement.inplaceEditor) { + // The focus was already removed from the current inplace editor, we should not + // refocus the editable attribute. + return; + } + + const container = this.markup.getContainer(this.node); + + const activeAttrs = [...this.attrList.childNodes].filter( + el => el.style.display != "none" + ); + const attributeIndex = activeAttrs.indexOf(attrNode); + + const onMutations = (this._editedAttributeObserver = mutations => { + let isDeletedAttribute = false; + let isNewAttribute = false; + + for (const mutation of mutations) { + const inContainer = + this.markup.getContainer(mutation.target) === container; + if (!inContainer) { + continue; + } + + const isOriginalAttribute = mutation.attributeName === attrName; + + isDeletedAttribute = + isDeletedAttribute || + (isOriginalAttribute && mutation.newValue === null); + isNewAttribute = isNewAttribute || mutation.attributeName !== attrName; + } + + const isModifiedOrder = isDeletedAttribute && isNewAttribute; + this._editedAttributeObserver = null; + + // "Deleted" attributes are merely hidden, so filter them out. + const visibleAttrs = [...this.attrList.childNodes].filter( + el => el.style.display != "none" + ); + let activeEditor; + if (visibleAttrs.length) { + 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). + const 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 display badge is clicked. Toggles on the flexbox/grid highlighter for + * the selected node if it is a grid container. + * + * Event handling for highlighter events is delegated up to the Markup view panel. + * When a flexbox/grid highlighter is shown or hidden, the corresponding badge will + * be marked accordingly. See MarkupView.handleHighlighterEvent() + */ + async onDisplayBadgeClick(event) { + event.stopPropagation(); + + const target = event.target; + + if ( + target.dataset.display === "flex" || + target.dataset.display === "inline-flex" + ) { + await this.highlighters.toggleFlexboxHighlighter(this.node, "markup"); + } + + if ( + target.dataset.display === "grid" || + target.dataset.display === "inline-grid" || + target.dataset.display === "subgrid" + ) { + // Don't toggle the grid highlighter if the max number of new grid highlighters + // allowed has been reached. + if (!this.highlighters.canGridHighlighterToggle(this.node)) { + return; + } + + await this.highlighters.toggleGridHighlighter(this.node, "markup"); + } + }, + + async onCustomBadgeClick() { + const { url, line, column } = this.node.customElementLocation; + + this.markup.toolbox.viewSourceInDebugger( + url, + line, + column, + null, + "show_custom_element" + ); + }, + + onExpandBadgeClick() { + this.container.expandContainer(); + }, + + /** + * Called when the scrollable badge is clicked. Shows the overflow causing elements and + * highlights their container if the scroll badge is active. + */ + async onScrollableBadgeClick() { + this.highlightingOverflowCausingElements = + this._scrollableBadge.classList.toggle("active"); + + const { nodes } = await this.node.walkerFront.getOverflowCausingElements( + this.node + ); + + for (const node of nodes) { + if (this.highlightingOverflowCausingElements) { + await this.markup.showNode(node); + } + + const markupContainer = this.markup.getContainer(node); + + if (markupContainer) { + markupContainer.editor.setOverflowHighlight( + this.highlightingOverflowCausingElements + ); + } + } + + this.markup.telemetry.scalarAdd( + "devtools.markup.scrollable.badge.clicked", + 1 + ); + }, + + /** + * Called when the tag name editor has is done editing. + */ + onTagEdit(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.node.walkerFront.editTagName(this.node, newTagName).catch(() => { + // Failed to edit the tag name, cancel the reselection. + this.markup.cancelReselectOnRemoved(); + }); + }, + + destroy() { + if (this._displayBadge) { + this._displayBadge.removeEventListener("click", this.onDisplayBadgeClick); + } + + if (this._customBadge) { + this._customBadge.removeEventListener("click", this.onCustomBadgeClick); + } + + if (this._scrollableBadge) { + this._scrollableBadge.removeEventListener( + "click", + this.onScrollableBadgeClick + ); + } + + this.expandBadge.removeEventListener("click", this.onExpandBadgeClick); + + for (const 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 0000000000..fbff0f3e2e --- /dev/null +++ b/devtools/client/inspector/markup/views/html-editor.js @@ -0,0 +1,177 @@ +/* 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("resource://devtools/client/shared/sourceeditor/editor.js"); +const EventEmitter = require("resource://devtools/shared/event-emitter.js"); + +/** + * 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); + + const 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); + this.editorInner.addEventListener("click", stopPropagation); + 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() { + const 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(element) { + this._detach(); + this._attachedElement = element; + element.classList.add("html-editor-container"); + this.refresh(); + }, + + /** + * Unanchor the editor from an element. + */ + _detach() { + 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(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.editor.clearHistory(); + + 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(shouldCommit) { + if (!this._visible) { + return; + } + + this.container.style.display = "none"; + this._detach(); + + const newValue = this.editor.getText(); + const valueHasChanged = this._originalValue !== newValue; + const preventCommit = shouldCommit === false || !valueHasChanged; + this._originalValue = undefined; + this._visible = undefined; + this.emit("popuphidden", !preventCommit, newValue); + }, + + /** + * Destroy this object and unbind all event handlers + */ + destroy() { + this.doc.defaultView.removeEventListener("resize", this.refresh, true); + this.container.removeEventListener("click", this.hide); + this.editorInner.removeEventListener("click", stopPropagation); + + 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 0000000000..80e3a9f325 --- /dev/null +++ b/devtools/client/inspector/markup/views/markup-container.js @@ -0,0 +1,868 @@ +/* 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 { KeyCodes } = require("resource://devtools/client/shared/keycodes.js"); +const { + flashElementOn, + flashElementOff, +} = require("resource://devtools/client/inspector/markup/utils.js"); + +loader.lazyRequireGetter( + this, + "wrapMoveFocus", + "resource://devtools/client/shared/focus.js", + true +); + +const DRAG_DROP_MIN_INITIAL_DISTANCE = 10; +const TYPES = { + TEXT_CONTAINER: "textcontainer", + ELEMENT_CONTAINER: "elementcontainer", + READ_ONLY_CONTAINER: "readonlycontainer", +}; + +/** + * 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 = { + // Get the UndoStack from the MarkupView. + get undo() { + // undo is a lazy getter in the MarkupView. + return this.markup.undo; + }, + + /* + * 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} type + * The type of container to build. One of TYPES.TEXT_CONTAINER, + * TYPES.ELEMENT_CONTAINER, TYPES.READ_ONLY_CONTAINER + */ + initialize(markupView, node, type) { + this.markup = markupView; + this.node = node; + this.type = type; + this.win = this.markup._frame.contentWindow; + this.id = "treeitem-" + markupContainerID++; + this.htmlElt = this.win.document.documentElement; + + this.buildMarkup(); + + this.elt.container = this; + + this._onMouseDown = this._onMouseDown.bind(this); + this._onToggle = this._onToggle.bind(this); + this._onKeyDown = this._onKeyDown.bind(this); + + // Binding event listeners + this.elt.addEventListener("mousedown", this._onMouseDown); + this.elt.addEventListener("dblclick", this._onToggle); + if (this.expander) { + this.expander.addEventListener("click", this._onToggle); + } + + // Marking the node as shown or hidden + this.updateIsDisplayed(); + + if (node.isShadowRoot) { + this.markup.telemetry.scalarSet( + "devtools.shadowdom.shadow_root_displayed", + true + ); + } + }, + + buildMarkup() { + this.elt = this.win.document.createElement("li"); + this.elt.classList.add("child", "collapsed"); + this.elt.setAttribute("role", "presentation"); + + this.tagLine = this.win.document.createElement("div"); + this.tagLine.setAttribute("id", this.id); + this.tagLine.classList.add("tag-line"); + this.tagLine.setAttribute("role", "treeitem"); + this.tagLine.setAttribute("aria-level", this.level); + this.tagLine.setAttribute("aria-grabbed", this.isDragging); + this.elt.appendChild(this.tagLine); + + this.mutationMarker = this.win.document.createElement("div"); + this.mutationMarker.classList.add("markup-tag-mutation-marker"); + this.mutationMarker.style.setProperty("--markup-level", this.level); + this.tagLine.appendChild(this.mutationMarker); + + this.tagState = this.win.document.createElement("span"); + this.tagState.classList.add("tag-state"); + this.tagState.setAttribute("role", "presentation"); + this.tagLine.appendChild(this.tagState); + + if (this.type !== TYPES.TEXT_CONTAINER) { + this.expander = this.win.document.createElement("span"); + this.expander.classList.add("theme-twisty", "expander"); + this.expander.setAttribute("role", "presentation"); + this.tagLine.appendChild(this.expander); + } + + this.children = this.win.document.createElement("ul"); + this.children.classList.add("children"); + this.children.setAttribute("role", "group"); + this.elt.appendChild(this.children); + }, + + toString() { + return "[MarkupContainer for " + this.node + "]"; + }, + + isPreviewable() { + if (this.node.tagName && !this.node.isPseudoElement) { + const tagName = this.node.tagName.toLowerCase(); + const srcAttr = this.editor.getAttributeElement("src"); + const isImage = tagName === "img" && srcAttr; + const 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() { + 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() { + if (!this.canFocus) { + return; + } + + this.canFocus = false; + const 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() { + if (!this.expander) { + return; + } + + if (this.showExpander) { + this.elt.classList.add("expandable"); + this.expander.style.visibility = "visible"; + // Update accessibility expanded state. + this.tagLine.setAttribute("aria-expanded", this.expanded); + } else { + this.elt.classList.remove("expandable"); + 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() { + this.children.setAttribute( + "role", + this.hasChildren ? "group" : "presentation" + ); + }, + + /** + * Set an appropriate DOM tree depth level for a node and its subtree. + */ + updateLevel() { + // ARIA level should already be set when the container markup is created. + const currentLevel = this.tagLine.getAttribute("aria-level"); + const newLevel = this.level; + if (currentLevel === newLevel) { + // If level did not change, ignore this node and its subtree. + return; + } + + this.tagLine.setAttribute("aria-level", newLevel); + const 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() { + 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(value) { + if (!this.expander) { + return; + } + + if (!this.canExpand) { + value = false; + } + + if (this.mustExpand) { + value = true; + } + + if (value && this.elt.classList.contains("collapsed")) { + this.showCloseTagLine(); + + this.elt.classList.remove("collapsed"); + this.expander.setAttribute("open", ""); + this.hovered = false; + this.markup.emit("expanded"); + } else if (!value) { + this.hideCloseTagLine(); + + this.elt.classList.add("collapsed"); + this.expander.removeAttribute("open"); + this.markup.emit("collapsed"); + } + + if (this.showExpander) { + this.tagLine.setAttribute("aria-expanded", this.expanded); + } + + if (this.node.isShadowRoot) { + this.markup.telemetry.scalarSet( + "devtools.shadowdom.shadow_root_expanded", + true + ); + } + }, + + /** + * Expanding a node means cloning its "inline" closing tag into a new + * tag-line that the user can interact with and showing the children. + */ + showCloseTagLine() { + // Only element containers display a closing tag line. #document has no closing line. + if (this.type !== TYPES.ELEMENT_CONTAINER) { + return; + } + + // Retrieve the closest .close node for this container. + const closingTag = this.elt.querySelector(".close"); + if (!closingTag) { + return; + } + + // Create the closing tag-line element if not already created. + if (!this.closeTagLine) { + const line = this.markup.doc.createElement("div"); + line.classList.add("tag-line"); + // Closing tag is not important for accessibility. + line.setAttribute("role", "presentation"); + + const 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); + }, + + /** + * Hide the closing tag-line element which should only be displayed when the container + * is expanded. + */ + hideCloseTagLine() { + if (!this.closeTagLine) { + return; + } + + this.elt.removeChild(this.closeTagLine); + this.closeTagLine = undefined; + }, + + parentContainer() { + 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) { + const 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() { + const 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() && + this.node.parentNode().tagName !== null + ); + }, + + isSlotted() { + return false; + }, + + _onKeyDown(event) { + const { target, keyCode, shiftKey } = event; + const 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. + const next = wrapMoveFocus( + this.focusableElms, + target.nextSibling, + shiftKey + ); + if (next) { + event.preventDefault(); + // Keep the editing state if possible. + if (next._editable) { + const e = this.markup.doc.createEvent("Event"); + e.initEvent(next._trigger, true, true); + next.dispatchEvent(e); + } + } + } else { + const next = wrapMoveFocus(this.focusableElms, 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(event) { + const { target, button, metaKey, ctrlKey } = event; + const isLeftClick = button === 0; + const isMiddleClick = button === 1; + const isMetaClick = isLeftClick && (metaKey || ctrlKey); + + // The "show more nodes" button already has its onclick, so early return. + if (target.nodeName === "button") { + return; + } + + // Bail out when clicking on arrow expanders to avoid selecting the row. + if (target.classList.contains("expander")) { + 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(); + } + + // Middle clicks will trigger the scroll lock feature to turn on. + // The toolbox is normally responsible for calling preventDefault when + // needed, but we prevent markup-view mousedown events from bubbling up (via + // stopPropagation). So we have to preventDefault here as well in order to + // avoid this issue. + if (isMiddleClick) { + event.preventDefault(); + } + + // Follow attribute links if middle or meta click. + if (isMiddleClick || isMetaClick) { + const link = target.dataset.link; + const type = target.dataset.type; + // Make container tabbable descendants not tabbable (by default). + this.canFocus = false; + this.markup.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; + this.markup._draggedContainer = this; + } + }, + + /** + * On mouse up, stop dragging. + * This handler is called from the markup view, to reduce number of listeners. + */ + async onMouseUp() { + this._isPreDragging = false; + this.markup._draggedContainer = null; + + if (this.isDragging) { + this.cancelDragging(); + + if (!this.markup.dropTargetNodes) { + return; + } + + const { nextSibling, parent } = this.markup.dropTargetNodes; + const { walkerFront } = parent; + await walkerFront.insertBefore(this.node, parent, nextSibling); + this.markup.emit("drop-completed"); + } + }, + + /** + * On mouse move, move the dragged element and indicate the drop target. + * This handler is called from the markup view, to reduce number of listeners. + */ + onMouseMove(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. + const 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. + const position = + this.elt.nextElementSibling || + this.markup.getContainer(this.node.parentNode()).closeTagLine; + this.markup.indicateDragTarget(position); + } + + if (this.isDragging) { + const 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; + } + + const diff = y - this._dragStartY + this.win.scrollY; + this.elt.style.top = diff + "px"; + + const el = this.markup.doc.elementFromPoint(x, y); + this.markup.indicateDropTarget(el); + } + }, + + cancelDragging() { + 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() { + if (!this.selected) { + flashElementOn(this.tagState, { + foregroundElt: this.editor.elt, + backgroundClass: "theme-bg-contrast", + }); + if (this._flashMutationTimer) { + clearTimeout(this._flashMutationTimer); + this._flashMutationTimer = null; + } + this._flashMutationTimer = setTimeout(() => { + flashElementOff(this.tagState, { + foregroundElt: this.editor.elt, + backgroundClass: "theme-bg-contrast", + }); + }, 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("tag-hover"); + } + if (this.closeTagLine) { + this.closeTagLine + .querySelector(".tag-state") + .classList.add("tag-hover"); + } + } else { + this.tagState.classList.remove("tag-hover"); + if (this.closeTagLine) { + this.closeTagLine + .querySelector(".tag-state") + .classList.remove("tag-hover"); + } + } + }, + + /** + * 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) { + const 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(mutationBreakpoints) { + if (this.node.pseudoClassLocks.length) { + this.elt.classList.add("pseudoclass-locked"); + } else { + this.elt.classList.remove("pseudoclass-locked"); + } + + if (mutationBreakpoints) { + const allMutationsDisabled = Array.from( + mutationBreakpoints.values() + ).every(element => element === false); + + if (mutationBreakpoints.size > 0) { + this.mutationMarker.classList.add("has-mutations"); + this.mutationMarker.classList.toggle( + "mutation-breakpoint-disabled", + allMutationsDisabled + ); + } else { + this.mutationMarker.classList.remove("has-mutations"); + } + } + + this.updateIsDisplayed(); + + if (this.editor.update) { + this.editor.update(); + } + }, + + /** + * Try to put keyboard focus on the current editor. + */ + focus() { + // Elements with tabindex of -1 are not focusable. + const focusable = this.editor.elt.querySelector("[tabindex='0']"); + if (focusable) { + focusable.focus(); + } + }, + + _onToggle(event) { + event.stopPropagation(); + + // Prevent the html tree from expanding when an event bubble, display or scrollable + // node is clicked. + if ( + event.target.dataset.event || + event.target.dataset.display || + event.target.dataset.scrollable + ) { + return; + } + + this.expandContainer(event.altKey); + }, + + /** + * Expands the markup container if it has children. + * + * @param {Boolean} applyToDescendants + * Whether all descendants should also be expanded/collapsed + */ + expandContainer(applyToDescendants) { + if (this.hasChildren) { + this.markup.setNodeExpanded( + this.node, + !this.expanded, + applyToDescendants + ); + } + }, + + /** + * Get rid of event listeners and references, when the container is no longer + * needed + */ + destroy() { + // Remove event listeners + this.elt.removeEventListener("mousedown", this._onMouseDown); + this.elt.removeEventListener("dblclick", this._onToggle); + this.tagLine.removeEventListener("keydown", this._onKeyDown, true); + + if (this.markup._draggedContainer === this) { + this.markup._draggedContainer = null; + } + + this.win = null; + this.htmlElt = null; + + if (this.expander) { + this.expander.removeEventListener("click", this._onToggle); + } + + // 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 0000000000..9be0f159ee --- /dev/null +++ b/devtools/client/inspector/markup/views/moz.build @@ -0,0 +1,19 @@ +# -*- 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", + "slotted-node-container.js", + "slotted-node-editor.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 0000000000..b48aad88a1 --- /dev/null +++ b/devtools/client/inspector/markup/views/read-only-container.js @@ -0,0 +1,36 @@ +/* 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 ReadOnlyEditor = require("resource://devtools/client/inspector/markup/views/read-only-editor.js"); +const MarkupContainer = require("resource://devtools/client/inspector/markup/views/markup-container.js"); +const { extend } = require("resource://devtools/shared/extend.js"); + +/** + * 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 = 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 0000000000..009abd5af0 --- /dev/null +++ b/devtools/client/inspector/markup/views/read-only-editor.js @@ -0,0 +1,82 @@ +/* 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("resource://devtools/shared/dom-node-constants.js"); + +/** + * Creates an editor for non-editable nodes. + */ +function ReadOnlyEditor(container, node) { + this.container = container; + this.markup = this.container.markup; + this.buildMarkup(); + + if (node.isPseudoElement) { + this.tag.classList.add("theme-fg-color3"); + if (node.isMarkerPseudoElement) { + this.tag.textContent = "::marker"; + } else if (node.isBeforePseudoElement) { + this.tag.textContent = "::before"; + } else if (node.isAfterPseudoElement) { + this.tag.textContent = "::after"; + } + } else if (node.nodeType == nodeConstants.DOCUMENT_TYPE_NODE) { + this.elt.classList.add("comment", "doctype"); + this.tag.textContent = node.doctypeString; + } else if (node.isShadowRoot) { + this.tag.textContent = `#shadow-root (${node.shadowRootMode})`; + } else { + this.tag.textContent = node.nodeName; + } + + // Make the "tag" part of this editor focusable. + this.tag.setAttribute("tabindex", "-1"); +} + +ReadOnlyEditor.prototype = { + buildMarkup() { + const doc = this.markup.doc; + + this.elt = doc.createElement("span"); + this.elt.classList.add("editor"); + + this.tag = doc.createElement("span"); + this.tag.classList.add("tag"); + this.elt.appendChild(this.tag); + }, + + destroy() { + // We might be already destroyed. + if (!this.elt) { + return; + } + + this.elt.remove(); + this.elt = null; + this.tag = null; + }, + + /** + * Show overflow highlight if showOverflowHighlight is true, otherwise hide it. + * + * @param {Boolean} showOverflowHighlight + */ + setOverflowHighlight(showOverflowHighlight) { + this.container.tagState.classList.toggle( + "overflow-causing-highlighted", + showOverflowHighlight + ); + }, + + /** + * Stub method for consistency with ElementEditor. + */ + getInfoAtNode() { + 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 0000000000..8b34b85843 --- /dev/null +++ b/devtools/client/inspector/markup/views/root-container.js @@ -0,0 +1,60 @@ +/* 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() {}, + destroy() {}, + + /** + * If the node has children, return the list of containers for all these children. + * @return {Array} An array of child containers or null. + */ + getChildContainers() { + return [...this.children.children] + .filter(node => node.container) + .map(node => node.container); + }, + + /** + * Set the expanded state of the container node. + * @param {Boolean} value + */ + setExpanded() {}, + + /** + * Set an appropriate role of the container's children node. + */ + setChildrenRole() {}, + + /** + * Set an appropriate DOM tree depth level for a node and its subtree. + */ + updateLevel() {}, + + isSlotted() { + return false; + }, +}; + +module.exports = RootContainer; diff --git a/devtools/client/inspector/markup/views/slotted-node-container.js b/devtools/client/inspector/markup/views/slotted-node-container.js new file mode 100644 index 0000000000..6d128cfc39 --- /dev/null +++ b/devtools/client/inspector/markup/views/slotted-node-container.js @@ -0,0 +1,76 @@ +/* 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 SlottedNodeEditor = require("resource://devtools/client/inspector/markup/views/slotted-node-editor.js"); +const MarkupContainer = require("resource://devtools/client/inspector/markup/views/markup-container.js"); +const { extend } = require("resource://devtools/shared/extend.js"); + +function SlottedNodeContainer(markupView, node) { + MarkupContainer.prototype.initialize.call( + this, + markupView, + node, + "slottednodecontainer" + ); + + this.editor = new SlottedNodeEditor(this, node); + this.tagLine.appendChild(this.editor.elt); + this.hasChildren = false; +} + +SlottedNodeContainer.prototype = extend(MarkupContainer.prototype, { + _onMouseDown(event) { + if (event.target.classList.contains("reveal-link")) { + event.stopPropagation(); + event.preventDefault(); + return; + } + MarkupContainer.prototype._onMouseDown.call(this, event); + }, + + /** + * Slotted node containers never display children and should not react to toggle. + */ + _onToggle(event) { + event.stopPropagation(); + }, + + _revealFromSlot() { + const reason = "reveal-from-slot"; + this.markup.inspector.selection.setNodeFront(this.node, { reason }); + this.markup.telemetry.scalarSet( + "devtools.shadowdom.reveal_link_clicked", + true + ); + }, + + _onKeyDown(event) { + MarkupContainer.prototype._onKeyDown.call(this, event); + + const isActionKey = event.code == "Enter" || event.code == "Space"; + if (event.target.classList.contains("reveal-link") && isActionKey) { + this._revealFromSlot(); + } + }, + + async onContainerClick(event) { + if (!event.target.classList.contains("reveal-link")) { + return; + } + + this._revealFromSlot(); + }, + + isDraggable() { + return false; + }, + + isSlotted() { + return true; + }, +}); + +module.exports = SlottedNodeContainer; diff --git a/devtools/client/inspector/markup/views/slotted-node-editor.js b/devtools/client/inspector/markup/views/slotted-node-editor.js new file mode 100644 index 0000000000..d70311e4dc --- /dev/null +++ b/devtools/client/inspector/markup/views/slotted-node-editor.js @@ -0,0 +1,63 @@ +/* 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 { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const INSPECTOR_L10N = new LocalizationHelper( + "devtools/client/locales/inspector.properties" +); + +function SlottedNodeEditor(container, node) { + this.container = container; + this.markup = this.container.markup; + this.buildMarkup(); + this.tag.textContent = "<" + node.nodeName.toLowerCase() + ">"; + + // Make the "tag" part of this editor focusable. + this.tag.setAttribute("tabindex", "-1"); +} + +SlottedNodeEditor.prototype = { + buildMarkup() { + const doc = this.markup.doc; + + this.elt = doc.createElement("span"); + this.elt.classList.add("editor"); + + this.tag = doc.createElement("span"); + this.tag.classList.add("tag"); + this.elt.appendChild(this.tag); + + this.revealLink = doc.createElement("span"); + this.revealLink.setAttribute("role", "link"); + this.revealLink.setAttribute("tabindex", -1); + this.revealLink.title = INSPECTOR_L10N.getStr( + "markupView.revealLink.tooltip" + ); + this.revealLink.classList.add("reveal-link"); + this.elt.appendChild(this.revealLink); + }, + + destroy() { + // We might be already destroyed. + if (!this.elt) { + return; + } + + this.elt.remove(); + this.elt = null; + this.tag = null; + this.revealLink = null; + }, + + /** + * Stub method for consistency with ElementEditor. + */ + getInfoAtNode() { + return null; + }, +}; + +module.exports = SlottedNodeEditor; 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 0000000000..1b240cb1df --- /dev/null +++ b/devtools/client/inspector/markup/views/text-container.js @@ -0,0 +1,44 @@ +/* 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("resource://devtools/shared/dom-node-constants.js"); +const TextEditor = require("resource://devtools/client/inspector/markup/views/text-editor.js"); +const MarkupContainer = require("resource://devtools/client/inspector/markup/views/markup-container.js"); +const { extend } = require("resource://devtools/shared/extend.js"); + +/** + * 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 = 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 0000000000..62b59c74cd --- /dev/null +++ b/devtools/client/inspector/markup/views/text-editor.js @@ -0,0 +1,143 @@ +/* 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 { + createFactory, +} = require("resource://devtools/client/shared/vendor/react.js"); + +const TextNode = createFactory( + require("resource://devtools/client/inspector/markup/components/TextNode.js") +); + +loader.lazyRequireGetter( + this, + "getAutocompleteMaxWidth", + "resource://devtools/client/inspector/markup/utils.js", + true +); +loader.lazyRequireGetter( + this, + "getLongString", + "resource://devtools/client/inspector/shared/utils.js", + true +); +loader.lazyRequireGetter( + this, + "InplaceEditor", + "resource://devtools/client/shared/inplace-editor.js", + true +); + +/** + * 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} type + * The type of editor to build. This can be either 'text' or 'comment'. + */ +function TextEditor(container, node, type) { + this.container = container; + this.markup = this.container.markup; + this.node = node; + this._selected = false; + + this.showTextEditor = this.showTextEditor.bind(this); + + this.buildMarkup(type); +} + +TextEditor.prototype = { + buildMarkup(type) { + const doc = this.markup.doc; + + this.elt = doc.createElement("span"); + this.elt.classList.add("editor", type); + + getLongString(this.node.getNodeValue()).then(value => { + this.textNode = this.ReactDOM.render( + TextNode({ + showTextEditor: this.showTextEditor, + type, + value, + }), + this.elt + ); + }); + }, + + get ReactDOM() { + // Reuse the toolbox's ReactDOM to avoid loading react-dom.js again in the + // Inspector's BrowserLoader. + return this.container.markup.inspector.ReactDOM; + }, + + get selected() { + return this._selected; + }, + + set selected(value) { + if (value === this._selected) { + return; + } + this._selected = value; + this.update(); + }, + + showTextEditor(element) { + new InplaceEditor({ + cssProperties: this.markup.inspector.cssProperties, + done: (val, commit) => { + if (!commit) { + return; + } + getLongString(this.node.getNodeValue()).then(oldValue => { + this.container.undo.do( + () => { + this.node.setNodeValue(val); + }, + () => { + this.node.setNodeValue(oldValue); + } + ); + }); + }, + element, + maxWidth: () => getAutocompleteMaxWidth(element, this.container.elt), + multiline: true, + stopOnReturn: true, + trimOutput: false, + }); + }, + + async update() { + try { + const value = await getLongString(this.node.getNodeValue()); + + if (this.textNode.state.value !== value) { + this.textNode.setState({ value }); + } + } catch (e) { + console.error(e); + } + }, + + destroy() { + this.ReactDOM.unmountComponentAtNode(this.elt); + }, + + /** + * Stub method for consistency with ElementEditor. + */ + getInfoAtNode() { + return null; + }, +}; + +module.exports = TextEditor; |