From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../inspector/markup/views/element-container.js | 260 +++++ .../inspector/markup/views/element-editor.js | 1213 ++++++++++++++++++++ .../client/inspector/markup/views/html-editor.js | 177 +++ .../inspector/markup/views/markup-container.js | 900 +++++++++++++++ devtools/client/inspector/markup/views/moz.build | 19 + .../inspector/markup/views/read-only-container.js | 36 + .../inspector/markup/views/read-only-editor.js | 82 ++ .../inspector/markup/views/root-container.js | 60 + .../markup/views/slotted-node-container.js | 76 ++ .../inspector/markup/views/slotted-node-editor.js | 63 + .../inspector/markup/views/text-container.js | 44 + .../client/inspector/markup/views/text-editor.js | 143 +++ 12 files changed, 3073 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/slotted-node-container.js create mode 100644 devtools/client/inspector/markup/views/slotted-node-editor.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 0000000000..c47edccef1 --- /dev/null +++ b/devtools/client/inspector/markup/views/element-container.js @@ -0,0 +1,260 @@ +/* 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; + } + + event.target.setAttribute("aria-pressed", "true"); + 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"; + this.editor._eventBadge.setAttribute("aria-pressed", "false"); + } + }, 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); + tooltip.focus(); + }, + + /** + * 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..f538c2f6a9 --- /dev/null +++ b/devtools/client/inspector/markup/views/element-editor.js @@ -0,0 +1,1213 @@ +/* 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, + ATTRIBUTE_TYPES, +} = 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("")); + }, + + 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.updateContainerBadge(); + 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("button"); + this._eventBadge.className = "inspector-badge interactive"; + this._eventBadge.dataset.event = "true"; + this._eventBadge.textContent = "event"; + this._eventBadge.title = INSPECTOR_L10N.getStr( + "markupView.event.tooltiptext2" + ); + this._eventBadge.setAttribute("aria-pressed", "false"); + // 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( + isInteractive ? "button" : "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._scrollableBadge.setAttribute("aria-pressed", "false"); + } + 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("button"); + 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); + + // Since the badge is a