/* 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 flags = require("resource://devtools/shared/flags.js"); const nodeConstants = require("resource://devtools/shared/dom-node-constants.js"); const nodeFilterConstants = require("resource://devtools/shared/dom-node-filter-constants.js"); const EventEmitter = require("resource://devtools/shared/event-emitter.js"); const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); const { PluralForm } = require("resource://devtools/shared/plural-form.js"); const AutocompletePopup = require("resource://devtools/client/shared/autocomplete-popup.js"); const KeyShortcuts = require("resource://devtools/client/shared/key-shortcuts.js"); const { scrollIntoViewIfNeeded, } = require("resource://devtools/client/shared/scroll.js"); const { PrefObserver } = require("resource://devtools/client/shared/prefs.js"); const MarkupElementContainer = require("resource://devtools/client/inspector/markup/views/element-container.js"); const MarkupReadOnlyContainer = require("resource://devtools/client/inspector/markup/views/read-only-container.js"); const MarkupTextContainer = require("resource://devtools/client/inspector/markup/views/text-container.js"); const RootContainer = require("resource://devtools/client/inspector/markup/views/root-container.js"); const WalkerEventListener = require("resource://devtools/client/inspector/shared/walker-event-listener.js"); loader.lazyRequireGetter( this, ["createDOMMutationBreakpoint", "deleteDOMMutationBreakpoint"], "resource://devtools/client/framework/actions/index.js", true ); loader.lazyRequireGetter( this, "MarkupContextMenu", "resource://devtools/client/inspector/markup/markup-context-menu.js" ); loader.lazyRequireGetter( this, "SlottedNodeContainer", "resource://devtools/client/inspector/markup/views/slotted-node-container.js" ); loader.lazyRequireGetter( this, "getLongString", "resource://devtools/client/inspector/shared/utils.js", true ); loader.lazyRequireGetter( this, "openContentLink", "resource://devtools/client/shared/link.js", true ); loader.lazyRequireGetter( this, "HTMLTooltip", "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js", true ); loader.lazyRequireGetter( this, "UndoStack", "resource://devtools/client/shared/undo.js", true ); loader.lazyRequireGetter( this, "clipboardHelper", "resource://devtools/shared/platform/clipboard.js" ); loader.lazyRequireGetter( this, "beautify", "resource://devtools/shared/jsbeautify/beautify.js" ); loader.lazyRequireGetter( this, "getTabPrefs", "resource://devtools/shared/indentation.js", true ); const INSPECTOR_L10N = new LocalizationHelper( "devtools/client/locales/inspector.properties" ); // Page size for pageup/pagedown const PAGE_SIZE = 10; const DEFAULT_MAX_CHILDREN = 100; const DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE = 50; const DRAG_DROP_AUTOSCROLL_EDGE_RATIO = 0.1; const DRAG_DROP_MIN_AUTOSCROLL_SPEED = 2; const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 8; const DRAG_DROP_HEIGHT_TO_SPEED = 500; const DRAG_DROP_HEIGHT_TO_SPEED_MIN = 0.5; const DRAG_DROP_HEIGHT_TO_SPEED_MAX = 1; const ATTR_COLLAPSE_ENABLED_PREF = "devtools.markup.collapseAttributes"; const ATTR_COLLAPSE_LENGTH_PREF = "devtools.markup.collapseAttributeLength"; const BEAUTIFY_HTML_ON_COPY_PREF = "devtools.markup.beautifyOnCopy"; /** * These functions are called when a shortcut (as defined in `_initShortcuts`) occurs. * Each property in the following object corresponds to one of the shortcut that is * handled by the markup-view. * Each property value is a function that takes the markup-view instance as only * argument, and returns a boolean that signifies whether the event should be consumed. * By default, the event gets consumed after the shortcut handler returns, * this means its propagation is stopped. If you do want the shortcut event * to continue propagating through DevTools, then return true from the handler. */ const shortcutHandlers = { // Localizable keys "markupView.hide.key": markupView => { const node = markupView._selectedContainer.node; const walkerFront = node.walkerFront; if (node.hidden) { walkerFront.unhideNode(node); } else { walkerFront.hideNode(node); } }, "markupView.edit.key": markupView => { markupView.beginEditingHTML(markupView._selectedContainer.node); }, "markupView.scrollInto.key": markupView => { markupView.scrollNodeIntoView(); }, // Generic keys Delete: markupView => { markupView.deleteNodeOrAttribute(); }, Backspace: markupView => { markupView.deleteNodeOrAttribute(true); }, Home: markupView => { const rootContainer = markupView.getContainer(markupView._rootNode); markupView.navigate(rootContainer.children.firstChild.container); }, Left: markupView => { if (markupView._selectedContainer.expanded) { markupView.collapseNode(markupView._selectedContainer.node); } else { const parent = markupView._selectionWalker().parentNode(); if (parent) { markupView.navigate(parent.container); } } }, Right: markupView => { if ( !markupView._selectedContainer.expanded && markupView._selectedContainer.hasChildren ) { markupView._expandContainer(markupView._selectedContainer); } else { const next = markupView._selectionWalker().nextNode(); if (next) { markupView.navigate(next.container); } } }, Up: markupView => { const previousNode = markupView._selectionWalker().previousNode(); if (previousNode) { markupView.navigate(previousNode.container); } }, Down: markupView => { const nextNode = markupView._selectionWalker().nextNode(); if (nextNode) { markupView.navigate(nextNode.container); } }, PageUp: markupView => { const walker = markupView._selectionWalker(); let selection = markupView._selectedContainer; for (let i = 0; i < PAGE_SIZE; i++) { const previousNode = walker.previousNode(); if (!previousNode) { break; } selection = previousNode.container; } markupView.navigate(selection); }, PageDown: markupView => { const walker = markupView._selectionWalker(); let selection = markupView._selectedContainer; for (let i = 0; i < PAGE_SIZE; i++) { const nextNode = walker.nextNode(); if (!nextNode) { break; } selection = nextNode.container; } markupView.navigate(selection); }, Enter: markupView => { if (!markupView._selectedContainer.canFocus) { markupView._selectedContainer.canFocus = true; markupView._selectedContainer.focus(); return false; } return true; }, Space: markupView => { if (!markupView._selectedContainer.canFocus) { markupView._selectedContainer.canFocus = true; markupView._selectedContainer.focus(); return false; } return true; }, Esc: markupView => { if (markupView.isDragging) { markupView.cancelDragging(); return false; } // Prevent cancelling the event when not // dragging, to allow the split console to be toggled. return true; }, }; /** * Vocabulary for the purposes of this file: * * MarkupContainer - the structure that holds an editor and its * immediate children in the markup panel. * - MarkupElementContainer: markup container for element nodes * - MarkupTextContainer: markup container for text / comment nodes * - MarkupReadonlyContainer: markup container for other nodes * Node - A content node. * object.elt - A UI element in the markup panel. */ /** * The markup tree. Manages the mapping of nodes to MarkupContainers, * updating based on mutations, and the undo/redo bindings. * * @param {Inspector} inspector * The inspector we're watching. * @param {iframe} frame * An iframe in which the caller has kindly loaded markup.xhtml. * @param {XULWindow} controllerWindow * Will enable the undo/redo feature from devtools/client/shared/undo. * Should be a XUL window, will typically point to the toolbox window. */ function MarkupView(inspector, frame, controllerWindow) { EventEmitter.decorate(this); this.controllerWindow = controllerWindow; this.inspector = inspector; this.highlighters = inspector.highlighters; this.walker = this.inspector.walker; this._frame = frame; this.win = this._frame.contentWindow; this.doc = this._frame.contentDocument; this._elt = this.doc.getElementById("root"); this.telemetry = this.inspector.telemetry; this._breakpointIDsInLocalState = new Map(); this._containersToUpdate = new Map(); this.maxChildren = Services.prefs.getIntPref( "devtools.markup.pagesize", DEFAULT_MAX_CHILDREN ); this.collapseAttributes = Services.prefs.getBoolPref( ATTR_COLLAPSE_ENABLED_PREF ); this.collapseAttributeLength = Services.prefs.getIntPref( ATTR_COLLAPSE_LENGTH_PREF ); // Creating the popup to be used to show CSS suggestions. // The popup will be attached to the toolbox document. this.popup = new AutocompletePopup(inspector.toolbox.doc, { autoSelect: true, }); this._containers = new Map(); // This weakmap will hold keys used with the _containers map, in order to retrieve the // slotted container for a given node front. this._slottedContainerKeys = new WeakMap(); // Binding functions that need to be called in scope. this._handleRejectionIfNotDestroyed = this._handleRejectionIfNotDestroyed.bind(this); this._isImagePreviewTarget = this._isImagePreviewTarget.bind(this); this._onWalkerMutations = this._onWalkerMutations.bind(this); this._onBlur = this._onBlur.bind(this); this._onContextMenu = this._onContextMenu.bind(this); this._onCopy = this._onCopy.bind(this); this._onCollapseAttributesPrefChange = this._onCollapseAttributesPrefChange.bind(this); this._onWalkerNodeStatesChanged = this._onWalkerNodeStatesChanged.bind(this); this._onFocus = this._onFocus.bind(this); this._onResourceAvailable = this._onResourceAvailable.bind(this); this._onTargetAvailable = this._onTargetAvailable.bind(this); this._onTargetDestroyed = this._onTargetDestroyed.bind(this); this._onMouseClick = this._onMouseClick.bind(this); this._onMouseMove = this._onMouseMove.bind(this); this._onMouseOut = this._onMouseOut.bind(this); this._onMouseUp = this._onMouseUp.bind(this); this._onNewSelection = this._onNewSelection.bind(this); this._onToolboxPickerCanceled = this._onToolboxPickerCanceled.bind(this); this._onToolboxPickerHover = this._onToolboxPickerHover.bind(this); this._onDomMutation = this._onDomMutation.bind(this); // Listening to various events. this._elt.addEventListener("blur", this._onBlur, true); this._elt.addEventListener("click", this._onMouseClick); this._elt.addEventListener("contextmenu", this._onContextMenu); this._elt.addEventListener("mousemove", this._onMouseMove); this._elt.addEventListener("mouseout", this._onMouseOut); this._frame.addEventListener("focus", this._onFocus); this.inspector.selection.on("new-node-front", this._onNewSelection); this._unsubscribeFromToolboxStore = this.inspector.toolbox.store.subscribe( this._onDomMutation ); if (flags.testing) { // In tests, we start listening immediately to avoid having to simulate a mousemove. this._initTooltips(); } this.win.addEventListener("copy", this._onCopy); this.win.addEventListener("mouseup", this._onMouseUp); this.inspector.toolbox.nodePicker.on( "picker-node-canceled", this._onToolboxPickerCanceled ); this.inspector.toolbox.nodePicker.on( "picker-node-hovered", this._onToolboxPickerHover ); // Event listeners for highlighter events this.onHighlighterShown = data => this.handleHighlighterEvent("highlighter-shown", data); this.onHighlighterHidden = data => this.handleHighlighterEvent("highlighter-hidden", data); this.inspector.highlighters.on("highlighter-shown", this.onHighlighterShown); this.inspector.highlighters.on( "highlighter-hidden", this.onHighlighterHidden ); this._onNewSelection(); if (this.inspector.selection.nodeFront) { this.expandNode(this.inspector.selection.nodeFront); } this._prefObserver = new PrefObserver("devtools.markup"); this._prefObserver.on( ATTR_COLLAPSE_ENABLED_PREF, this._onCollapseAttributesPrefChange ); this._prefObserver.on( ATTR_COLLAPSE_LENGTH_PREF, this._onCollapseAttributesPrefChange ); this._initShortcuts(); this._walkerEventListener = new WalkerEventListener(this.inspector, { "container-type-change": this._onWalkerNodeStatesChanged, "display-change": this._onWalkerNodeStatesChanged, "scrollable-change": this._onWalkerNodeStatesChanged, "overflow-change": this._onWalkerNodeStatesChanged, mutations: this._onWalkerMutations, }); this.resourceCommand = this.inspector.toolbox.resourceCommand; this.resourceCommand.watchResources([this.resourceCommand.TYPES.ROOT_NODE], { onAvailable: this._onResourceAvailable, }); this.targetCommand = this.inspector.commands.targetCommand; this.targetCommand.watchTargets({ types: [this.targetCommand.TYPES.FRAME], onAvailable: this._onTargetAvailable, onDestroyed: this._onTargetDestroyed, }); } MarkupView.prototype = { /** * How long does a node flash when it mutates (in ms). */ CONTAINER_FLASHING_DURATION: 500, _selectedContainer: null, get contextMenu() { if (!this._contextMenu) { this._contextMenu = new MarkupContextMenu(this); } return this._contextMenu; }, hasEventDetailsTooltip() { return !!this._eventDetailsTooltip; }, get eventDetailsTooltip() { if (!this._eventDetailsTooltip) { // This tooltip will be attached to the toolbox document. this._eventDetailsTooltip = new HTMLTooltip(this.toolbox.doc, { type: "arrow", consumeOutsideClicks: false, }); } return this._eventDetailsTooltip; }, get toolbox() { return this.inspector.toolbox; }, get undo() { if (!this._undo) { this._undo = new UndoStack(); this._undo.installController(this.controllerWindow); } return this._undo; }, _onDomMutation() { const domMutationBreakpoints = this.inspector.toolbox.store.getState().domMutationBreakpoints .breakpoints; const breakpointIDsInCurrentState = []; for (const breakpoint of domMutationBreakpoints) { const nodeFront = breakpoint.nodeFront; const mutationType = breakpoint.mutationType; const enabledStatus = breakpoint.enabled; breakpointIDsInCurrentState.push(breakpoint.id); // If breakpoint is not in local state if (!this._breakpointIDsInLocalState.has(breakpoint.id)) { this._breakpointIDsInLocalState.set(breakpoint.id, breakpoint); if (!this._containersToUpdate.has(nodeFront)) { this._containersToUpdate.set(nodeFront, new Map()); } } this._containersToUpdate.get(nodeFront).set(mutationType, enabledStatus); } // If a breakpoint is in local state but not current state, it has been // removed by the user. for (const id of this._breakpointIDsInLocalState.keys()) { if (breakpointIDsInCurrentState.includes(id) === false) { const nodeFront = this._breakpointIDsInLocalState.get(id).nodeFront; const mutationType = this._breakpointIDsInLocalState.get(id).mutationType; this._containersToUpdate.get(nodeFront).delete(mutationType); this._breakpointIDsInLocalState.delete(id); } } // Update each container for (const nodeFront of this._containersToUpdate.keys()) { const mutationBreakpoints = this._containersToUpdate.get(nodeFront); const container = this.getContainer(nodeFront); container.update(mutationBreakpoints); if (this._containersToUpdate.get(nodeFront).size === 0) { this._containersToUpdate.delete(nodeFront); } } }, /** * Handle promise rejections for various asynchronous actions, and only log errors if * the markup view still exists. * This is useful to silence useless errors that happen when the markup view is * destroyed while still initializing (and making protocol requests). */ _handleRejectionIfNotDestroyed(e) { if (!this._destroyed) { console.error(e); } }, _initTooltips() { if (this.imagePreviewTooltip) { return; } // The tooltips will be attached to the toolbox document. this.imagePreviewTooltip = new HTMLTooltip(this.toolbox.doc, { type: "arrow", useXulWrapper: true, }); this._enableImagePreviewTooltip(); }, _enableImagePreviewTooltip() { this.imagePreviewTooltip.startTogglingOnHover( this._elt, this._isImagePreviewTarget ); }, _disableImagePreviewTooltip() { if (!this.imagePreviewTooltip) { return; } this.imagePreviewTooltip.stopTogglingOnHover(); }, _onToolboxPickerHover(nodeFront) { this.showNode(nodeFront).then(() => { this._showNodeAsHovered(nodeFront); }, console.error); }, /** * If the element picker gets canceled, make sure and re-center the view on the * current selected element. */ _onToolboxPickerCanceled() { if (this._selectedContainer) { scrollIntoViewIfNeeded(this._selectedContainer.editor.elt); } }, isDragging: false, _draggedContainer: null, _onMouseMove(event) { // Note that in tests, we start listening immediately from the constructor to avoid having to simulate a mousemove. // Also note that initTooltips bails out if it is called many times, so it isn't an issue to call it a second // time from here in case tests are doing a mousemove. this._initTooltips(); let target = event.target; if (this._draggedContainer) { this._draggedContainer.onMouseMove(event); } // Auto-scroll if we're dragging. if (this.isDragging) { event.preventDefault(); this._autoScroll(event); return; } // Show the current container as hovered and highlight it. // This requires finding the current MarkupContainer (walking up the DOM). while (!target.container) { if (target.tagName.toLowerCase() === "body") { return; } target = target.parentNode; } const container = target.container; if (this._hoveredContainer !== container) { this._showBoxModel(container.node); } this._showContainerAsHovered(container); this.emit("node-hover"); }, /** * If focus is moved outside of the markup view document and there is a * selected container, make its contents not focusable by a keyboard. */ _onBlur(event) { if (!this._selectedContainer) { return; } const { relatedTarget } = event; if (relatedTarget && relatedTarget.ownerDocument === this.doc) { return; } if (this._selectedContainer) { this._selectedContainer.clearFocus(); } }, _onContextMenu(event) { this.contextMenu.show(event); }, /** * Executed on each mouse-move while a node is being dragged in the view. * Auto-scrolls the view to reveal nodes below the fold to drop the dragged * node in. */ _autoScroll(event) { const docEl = this.doc.documentElement; if (this._autoScrollAnimationFrame) { this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); } // Auto-scroll when the mouse approaches top/bottom edge. const fromBottom = docEl.clientHeight - event.pageY + this.win.scrollY; const fromTop = event.pageY - this.win.scrollY; const edgeDistance = Math.min( DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE, docEl.clientHeight * DRAG_DROP_AUTOSCROLL_EDGE_RATIO ); // The smaller the screen, the slower the movement. const heightToSpeedRatio = Math.max( DRAG_DROP_HEIGHT_TO_SPEED_MIN, Math.min( DRAG_DROP_HEIGHT_TO_SPEED_MAX, docEl.clientHeight / DRAG_DROP_HEIGHT_TO_SPEED ) ); if (fromBottom <= edgeDistance) { // Map our distance range to a speed range so that the speed is not too // fast or too slow. const speed = map( fromBottom, 0, edgeDistance, DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED ); this._runUpdateLoop(() => { docEl.scrollTop -= heightToSpeedRatio * (speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED); }); } if (fromTop <= edgeDistance) { const speed = map( fromTop, 0, edgeDistance, DRAG_DROP_MIN_AUTOSCROLL_SPEED, DRAG_DROP_MAX_AUTOSCROLL_SPEED ); this._runUpdateLoop(() => { docEl.scrollTop += heightToSpeedRatio * (speed - DRAG_DROP_MAX_AUTOSCROLL_SPEED); }); } }, /** * Run a loop on the requestAnimationFrame. */ _runUpdateLoop(update) { const loop = () => { update(); this._autoScrollAnimationFrame = this.win.requestAnimationFrame(loop); }; loop(); }, _onMouseClick(event) { // From the target passed here, let's find the parent MarkupContainer // and forward the event if needed. let parentNode = event.target; let container; while (parentNode !== this.doc.body) { if (parentNode.container) { container = parentNode.container; break; } parentNode = parentNode.parentNode; } if (typeof container.onContainerClick === "function") { // Forward the event to the container if it implements onContainerClick. container.onContainerClick(event); } }, _onMouseUp(event) { if (this._draggedContainer) { this._draggedContainer.onMouseUp(event); } this.indicateDropTarget(null); this.indicateDragTarget(null); if (this._autoScrollAnimationFrame) { this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); } }, _onCollapseAttributesPrefChange() { this.collapseAttributes = Services.prefs.getBoolPref( ATTR_COLLAPSE_ENABLED_PREF ); this.collapseAttributeLength = Services.prefs.getIntPref( ATTR_COLLAPSE_LENGTH_PREF ); this.update(); }, cancelDragging() { if (!this.isDragging) { return; } for (const [, container] of this._containers) { if (container.isDragging) { container.cancelDragging(); break; } } this.indicateDropTarget(null); this.indicateDragTarget(null); if (this._autoScrollAnimationFrame) { this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); } }, _hoveredContainer: null, /** * Show a NodeFront's container as being hovered * * @param {NodeFront} nodeFront * The node to show as hovered */ _showNodeAsHovered(nodeFront) { const container = this.getContainer(nodeFront); this._showContainerAsHovered(container); }, _showContainerAsHovered(container) { if (this._hoveredContainer === container) { return; } if (this._hoveredContainer) { this._hoveredContainer.hovered = false; } container.hovered = true; this._hoveredContainer = container; }, async _onMouseOut(event) { // Emulate mouseleave by skipping any relatedTarget inside the markup-view. if (this._elt.contains(event.relatedTarget)) { return; } if (this._autoScrollAnimationFrame) { this.win.cancelAnimationFrame(this._autoScrollAnimationFrame); } if (this.isDragging) { return; } await this._hideBoxModel(); if (this._hoveredContainer) { this._hoveredContainer.hovered = false; } this._hoveredContainer = null; this.emit("leave"); }, /** * Show the Box Model Highlighter on a given node front * * @param {NodeFront} nodeFront * The node for which to show the highlighter. * @param {Object} options * Configuration object with options for the Box Model Highlighter. * @return {Promise} Resolves after the highlighter for this nodeFront is shown. */ _showBoxModel(nodeFront, options) { return this.inspector.highlighters.showHighlighterTypeForNode( this.inspector.highlighters.TYPES.BOXMODEL, nodeFront, options ); }, /** * Hide the Box Model Highlighter for any node that may be highlighted. * * @return {Promise} Resolves when the highlighter is hidden. */ _hideBoxModel() { return this.inspector.highlighters.hideHighlighterType( this.inspector.highlighters.TYPES.BOXMODEL ); }, /** * Delegate handler for highlighter events. * * This is the place to observe for highlighter events, check the highlighter type and * event name, then react for example by modifying the DOM. * * @param {String} eventName * Highlighter event name. One of: "highlighter-hidden", "highlighter-shown" * @param {Object} data * Object with data associated with the highlighter event. * {String} data.type * Highlighter type * {NodeFront} data.nodeFront * NodeFront of the node associated with the highlighter event * {Object} data.options * Optional configuration passed to the highlighter when shown * {CustomHighlighterFront} data.highlighter * Highlighter instance * */ handleHighlighterEvent(eventName, data) { switch (data.type) { // Toggle the "active" CSS class name on flex and grid display badges next to // elements in the Markup view when a coresponding flex or grid highlighter is // shown or hidden for a node. case this.inspector.highlighters.TYPES.FLEXBOX: case this.inspector.highlighters.TYPES.GRID: const { nodeFront } = data; if (!nodeFront) { return; } // Find the badge corresponding to the node from the highlighter event payload. const container = this.getContainer(nodeFront); const badge = container?.editor?.displayBadge; if (badge) { const isActive = eventName == "highlighter-shown"; badge.classList.toggle("active", isActive); badge.setAttribute("aria-pressed", isActive); } // There is a limit to how many grid highlighters can be active at the same time. // If the limit was reached, disable all non-active grid badges. if (data.type === this.inspector.highlighters.TYPES.GRID) { // Matches badges for "grid", "inline-grid" and "subgrid" const selector = "[data-display*='grid']:not(.active)"; const isLimited = this.inspector.highlighters.isGridHighlighterLimitReached(); Array.from(this._elt.querySelectorAll(selector)).map(el => { el.classList.toggle("interactive", !isLimited); }); } break; } }, /** * Used by tests */ getSelectedContainer() { return this._selectedContainer; }, /** * Get the MarkupContainer object for a given node, or undefined if * none exists. * * @param {NodeFront} nodeFront * The node to get the container for. * @param {Boolean} slotted * true to get the slotted version of the container. * @return {MarkupContainer} The container for the provided node. */ getContainer(node, slotted) { const key = this._getContainerKey(node, slotted); return this._containers.get(key); }, /** * Register a given container for a given node/slotted node. * * @param {NodeFront} nodeFront * The node to set the container for. * @param {Boolean} slotted * true if the container represents the slotted version of the node. */ setContainer(node, container, slotted) { const key = this._getContainerKey(node, slotted); return this._containers.set(key, container); }, /** * Check if a MarkupContainer object exists for a given node/slotted node * * @param {NodeFront} nodeFront * The node to check. * @param {Boolean} slotted * true to check for a container matching the slotted version of the node. * @return {Boolean} True if a container exists, false otherwise. */ hasContainer(node, slotted) { const key = this._getContainerKey(node, slotted); return this._containers.has(key); }, _getContainerKey(node, slotted) { if (!slotted) { return node; } if (!this._slottedContainerKeys.has(node)) { this._slottedContainerKeys.set(node, { node }); } return this._slottedContainerKeys.get(node); }, _isContainerSelected(container) { if (!container) { return false; } const selection = this.inspector.selection; return ( container.node == selection.nodeFront && container.isSlotted() == selection.isSlotted() ); }, update() { const updateChildren = node => { this.getContainer(node).update(); for (const child of node.treeChildren()) { updateChildren(child); } }; // Start with the documentElement let documentElement; for (const node of this._rootNode.treeChildren()) { if (node.isDocumentElement === true) { documentElement = node; break; } } // Recursively update each node starting with documentElement. updateChildren(documentElement); }, /** * Executed when the mouse hovers over a target in the markup-view and is used * to decide whether this target should be used to display an image preview * tooltip. * Delegates the actual decision to the corresponding MarkupContainer instance * if one is found. * * @return {Promise} the promise returned by * MarkupElementContainer._isImagePreviewTarget */ async _isImagePreviewTarget(target) { // From the target passed here, let's find the parent MarkupContainer // and ask it if the tooltip should be shown if (this.isDragging) { return false; } let parent = target, container; while (parent) { if (parent.container) { container = parent.container; break; } parent = parent.parentNode; } if (container instanceof MarkupElementContainer) { return container.isImagePreviewTarget(target, this.imagePreviewTooltip); } return false; }, /** * Given the known reason, should the current selection be briefly highlighted * In a few cases, we don't want to highlight the node: * - If the reason is null (used to reset the selection), * - if it's "inspector-default-selection" (initial node selected, either when * opening the inspector or after a navigation/reload) * - if it's "picker-node-picked" or "picker-node-previewed" (node selected with the * node picker. Note that this does not include the "Inspect element" context menu, * which has a dedicated reason, "browser-context-menu"). * - if it's "test" (this is a special case for mochitest. In tests, we often * need to select elements but don't necessarily want the highlighter to come * and go after a delay as this might break test scenarios) * We also do not want to start a brief highlight timeout if the node is * already being hovered over, since in that case it will already be * highlighted. */ _shouldNewSelectionBeHighlighted() { const reason = this.inspector.selection.reason; const unwantedReasons = [ "inspector-default-selection", "nodeselected", "picker-node-picked", "picker-node-previewed", "test", ]; const isHighlight = this._isContainerSelected(this._hoveredContainer); return !isHighlight && reason && !unwantedReasons.includes(reason); }, /** * React to new-node-front selection events. * Highlights the node if needed, and make sure it is shown and selected in * the view. */ _onNewSelection(nodeFront, reason) { const selection = this.inspector.selection; // this will probably leak. // TODO: use resource api listeners? if (nodeFront) { nodeFront.walkerFront.on( "container-type-change", this._onWalkerNodeStatesChanged ); nodeFront.walkerFront.on( "display-change", this._onWalkerNodeStatesChanged ); nodeFront.walkerFront.on( "scrollable-change", this._onWalkerNodeStatesChanged ); nodeFront.walkerFront.on( "overflow-change", this._onWalkerNodeStatesChanged ); nodeFront.walkerFront.on("mutations", this._onWalkerMutations); } if (this.htmlEditor) { this.htmlEditor.hide(); } if (this._isContainerSelected(this._hoveredContainer)) { this._hoveredContainer.hovered = false; this._hoveredContainer = null; } if (!selection.isNode()) { this.unmarkSelectedNode(); return; } const done = this.inspector.updating("markup-view"); let onShowBoxModel; // Highlight the element briefly if needed. if (this._shouldNewSelectionBeHighlighted()) { onShowBoxModel = this._showBoxModel(nodeFront, { duration: this.inspector.HIGHLIGHTER_AUTOHIDE_TIMER, }); } const slotted = selection.isSlotted(); const smoothScroll = reason === "reveal-from-slot"; const onShow = this.showNode(selection.nodeFront, { slotted, smoothScroll }) .then(() => { // We could be destroyed by now. if (this._destroyed) { return Promise.reject("markupview destroyed"); } // Mark the node as selected. const container = this.getContainer(selection.nodeFront, slotted); this._markContainerAsSelected(container); // Make sure the new selection is navigated to. this.maybeNavigateToNewSelection(); return undefined; }) .catch(this._handleRejectionIfNotDestroyed); Promise.all([onShowBoxModel, onShow]).then(done); }, /** * Maybe make selected the current node selection's MarkupContainer depending * on why the current node got selected. */ async maybeNavigateToNewSelection() { const { reason, nodeFront } = this.inspector.selection; // The list of reasons that should lead to navigating to the node. const reasonsToNavigate = [ // If the user picked an element with the element picker. "picker-node-picked", // If the user shift-clicked (previewed) an element. "picker-node-previewed", // If the user selected an element with the browser context menu. "browser-context-menu", // If the user added a new node by clicking in the inspector toolbar. "node-inserted", ]; // If the user performed an action with a keyboard, move keyboard focus to // the markup tree container. if (reason && reason.endsWith("-keyboard")) { this.getContainer(this._rootNode).elt.focus(); } if (reasonsToNavigate.includes(reason)) { // not sure this is necessary const root = await nodeFront.walkerFront.getRootNode(); this.getContainer(root).elt.focus(); this.navigate(this.getContainer(nodeFront)); } }, /** * Create a TreeWalker to find the next/previous * node for selection. */ _selectionWalker(start) { const walker = this.doc.createTreeWalker( start || this._elt, nodeFilterConstants.SHOW_ELEMENT, function (element) { if ( element.container && element.container.elt === element && element.container.visible ) { return nodeFilterConstants.FILTER_ACCEPT; } return nodeFilterConstants.FILTER_SKIP; } ); walker.currentNode = this._selectedContainer.elt; return walker; }, _onCopy(evt) { // Ignore copy events from editors if (this._isInputOrTextarea(evt.target)) { return; } const selection = this.inspector.selection; if (selection.isNode()) { this.copyOuterHTML(); } evt.stopPropagation(); evt.preventDefault(); }, /** * Copy the outerHTML of the selected Node to the clipboard. */ copyOuterHTML() { if (!this.inspector.selection.isNode()) { return; } const node = this.inspector.selection.nodeFront; switch (node.nodeType) { case nodeConstants.ELEMENT_NODE: copyLongHTMLString(node.walkerFront.outerHTML(node)); break; case nodeConstants.COMMENT_NODE: getLongString(node.getNodeValue()).then(comment => { clipboardHelper.copyString(""); }); break; case nodeConstants.DOCUMENT_TYPE_NODE: clipboardHelper.copyString(node.doctypeString); break; } }, /** * Copy the innerHTML of the selected Node to the clipboard. */ copyInnerHTML() { const nodeFront = this.inspector.selection.nodeFront; if (!this.inspector.selection.isNode()) { return; } copyLongHTMLString(nodeFront.walkerFront.innerHTML(nodeFront)); }, /** * Given a type and link found in a node's attribute in the markup-view, * attempt to follow that link (which may result in opening a new tab, the * style editor or debugger). */ followAttributeLink(type, link) { if (!type || !link) { return; } const nodeFront = this.inspector.selection.nodeFront; if (type === "uri" || type === "cssresource" || type === "jsresource") { // Open link in a new tab. nodeFront.inspectorFront .resolveRelativeURL(link, this.inspector.selection.nodeFront) .then(url => { if (type === "uri") { openContentLink(url); } else if (type === "cssresource") { return this.toolbox.viewGeneratedSourceInStyleEditor(url); } else if (type === "jsresource") { return this.toolbox.viewGeneratedSourceInDebugger(url); } return null; }) .catch(console.error); } else if (type == "idref") { // Select the node in the same document. nodeFront.walkerFront .document(nodeFront) .then(doc => { return nodeFront.walkerFront .querySelector(doc, "#" + CSS.escape(link)) .then(node => { if (!node) { this.emit("idref-attribute-link-failed"); return; } this.inspector.selection.setNodeFront(node, { reason: "markup-attribute-link", }); }); }) .catch(console.error); } }, /** * Register all key shortcuts. */ _initShortcuts() { const shortcuts = new KeyShortcuts({ window: this.win, }); // Keep a pointer on shortcuts to destroy them when destroying the markup // view. this._shortcuts = shortcuts; this._onShortcut = this._onShortcut.bind(this); // Process localizable keys [ "markupView.hide.key", "markupView.edit.key", "markupView.scrollInto.key", ].forEach(name => { const key = INSPECTOR_L10N.getStr(name); shortcuts.on(key, event => this._onShortcut(name, event)); }); // Process generic keys: [ "Delete", "Backspace", "Home", "Left", "Right", "Up", "Down", "PageUp", "PageDown", "Esc", "Enter", "Space", ].forEach(key => { shortcuts.on(key, event => this._onShortcut(key, event)); }); }, /** * Key shortcut listener. */ _onShortcut(name, event) { if (this._isInputOrTextarea(event.target)) { return; } // If the selected element is a button (e.g. `flex` badge), we don't want to highjack // keyboard activation. if ( event.target.closest(":is(button, [role=button])") && (name === "Enter" || name === "Space") ) { return; } const handler = shortcutHandlers[name]; const shouldPropagate = handler(this); if (shouldPropagate) { return; } event.stopPropagation(); event.preventDefault(); }, /** * Check if a node is an input or textarea */ _isInputOrTextarea(element) { const name = element.tagName.toLowerCase(); return name === "input" || name === "textarea"; }, /** * If there's an attribute on the current node that's currently focused, then * delete this attribute, otherwise delete the node itself. * * @param {Boolean} moveBackward * If set to true and if we're deleting the node, focus the previous * sibling after deletion, otherwise the next one. */ deleteNodeOrAttribute(moveBackward) { const focusedAttribute = this.doc.activeElement ? this.doc.activeElement.closest(".attreditor") : null; if (focusedAttribute) { // The focused attribute might not be in the current selected container. const container = focusedAttribute.closest("li.child").container; container.removeAttribute(focusedAttribute.dataset.attr); } else { this.deleteNode(this._selectedContainer.node, moveBackward); } }, /** * Returns a value indicating whether a node can be deleted. * * @param {NodeFront} nodeFront * The node to test for deletion */ isDeletable(nodeFront) { return !( nodeFront.isDocumentElement || nodeFront.nodeType == nodeConstants.DOCUMENT_NODE || nodeFront.nodeType == nodeConstants.DOCUMENT_TYPE_NODE || nodeFront.nodeType == nodeConstants.DOCUMENT_FRAGMENT_NODE || nodeFront.isAnonymous ); }, /** * Delete a node from the DOM. * This is an undoable action. * * @param {NodeFront} node * The node to remove. * @param {Boolean} moveBackward * If set to true, focus the previous sibling, otherwise the next one. */ deleteNode(node, moveBackward) { if (!this.isDeletable(node)) { return; } const container = this.getContainer(node); // Retain the node so we can undo this... node.walkerFront .retainNode(node) .then(() => { const parent = node.parentNode(); let nextSibling = null; this.undo.do( () => { node.walkerFront.removeNode(node).then(siblings => { nextSibling = siblings.nextSibling; const prevSibling = siblings.previousSibling; let focusNode = moveBackward ? prevSibling : nextSibling; // If we can't move as the user wants, we move to the other direction. // If there is no sibling elements anymore, move to the parent node. if (!focusNode) { focusNode = nextSibling || prevSibling || parent; } const isNextSiblingText = nextSibling ? nextSibling.nodeType === nodeConstants.TEXT_NODE : false; const isPrevSiblingText = prevSibling ? prevSibling.nodeType === nodeConstants.TEXT_NODE : false; // If the parent had two children and the next or previous sibling // is a text node, then it now has only a single text node, is about // to be in-lined; and focus should move to the parent. if ( parent.numChildren === 2 && (isNextSiblingText || isPrevSiblingText) ) { focusNode = parent; } if (container.selected) { this.navigate(this.getContainer(focusNode)); } }); }, () => { const isValidSibling = nextSibling && !nextSibling.isPseudoElement; nextSibling = isValidSibling ? nextSibling : null; node.walkerFront.insertBefore(node, parent, nextSibling); } ); }) .catch(console.error); }, /** * Scroll the node into view. */ scrollNodeIntoView() { if (!this.inspector.selection.isNode()) { return; } this.inspector.selection.nodeFront.scrollIntoView(); }, async toggleMutationBreakpoint(name) { if (!this.inspector.selection.isElementNode()) { return; } const toolboxStore = this.inspector.toolbox.store; const nodeFront = this.inspector.selection.nodeFront; if (nodeFront.mutationBreakpoints[name]) { toolboxStore.dispatch(deleteDOMMutationBreakpoint(nodeFront, name)); } else { toolboxStore.dispatch(createDOMMutationBreakpoint(nodeFront, name)); } }, /** * If an editable item is focused, select its container. */ _onFocus(event) { let parent = event.target; while (!parent.container) { parent = parent.parentNode; } if (parent) { this.navigate(parent.container); } }, /** * Handle a user-requested navigation to a given MarkupContainer, * updating the inspector's currently-selected node. * * @param {MarkupContainer} container * The container we're navigating to. */ navigate(container) { if (!container) { return; } this._markContainerAsSelected(container, "treepanel"); }, /** * Make sure a node is included in the markup tool. * * @param {NodeFront} node * The node in the content document. * @param {Boolean} flashNode * Whether the newly imported node should be flashed * @param {Boolean} slotted * Whether we are importing the slotted version of the node. * @return {MarkupContainer} The MarkupContainer object for this element. */ importNode(node, flashNode, slotted) { if (!node) { return null; } if (this.hasContainer(node, slotted)) { return this.getContainer(node, slotted); } let container; const { nodeType, isPseudoElement } = node; if (node === node.walkerFront.rootNode) { container = new RootContainer(this, node); this._elt.appendChild(container.elt); } if (node === this.walker.rootNode) { this._rootNode = node; } else if (slotted) { container = new SlottedNodeContainer(this, node, this.inspector); } else if (nodeType == nodeConstants.ELEMENT_NODE && !isPseudoElement) { container = new MarkupElementContainer(this, node, this.inspector); } else if ( nodeType == nodeConstants.COMMENT_NODE || nodeType == nodeConstants.TEXT_NODE ) { container = new MarkupTextContainer(this, node, this.inspector); } else { container = new MarkupReadOnlyContainer(this, node, this.inspector); } if (flashNode) { container.flashMutation(); } this.setContainer(node, container, slotted); this._forceUpdateChildren(container); this.inspector.emit("container-created", container); return container; }, async _onResourceAvailable(resources) { for (const resource of resources) { if ( !this.resourceCommand || resource.resourceType !== this.resourceCommand.TYPES.ROOT_NODE || resource.isDestroyed() ) { // Only handle alive root-node resources continue; } if (resource.targetFront.isTopLevel && resource.isTopLevelDocument) { // The topmost root node will lead to the destruction and recreation of // the MarkupView. This is handled by the inspector. continue; } const parentNodeFront = resource.parentNode(); const container = this.getContainer(parentNodeFront); if (container) { // If there is no container for the parentNodeFront, the markup view is // currently not watching this part of the tree. this._forceUpdateChildren(container, { flash: true, updateLevel: true, }); } } }, _onTargetAvailable({ targetFront }) {}, _onTargetDestroyed({ targetFront, isModeSwitching }) { // Bug 1776250: We only watch targets in order to update containers which // might no longer be able to display children hosted in remote processes, // which corresponds to a Browser Toolbox mode switch. if (isModeSwitching) { const container = this.getContainer(targetFront.getParentNodeFront()); if (container) { this._forceUpdateChildren(container, { updateLevel: true, }); } } }, /** * Mutation observer used for included nodes. */ _onWalkerMutations(mutations) { for (const mutation of mutations) { const type = mutation.type; const target = mutation.target; const container = this.getContainer(target); if (!container) { // Container might not exist if this came from a load event for a node // we're not viewing. continue; } if ( type === "attributes" || type === "characterData" || type === "customElementDefined" || type === "events" || type === "pseudoClassLock" ) { container.update(); } else if ( type === "childList" || type === "slotchange" || type === "shadowRootAttached" ) { this._forceUpdateChildren(container, { flash: true, updateLevel: true, }); } else if (type === "inlineTextChild") { this._forceUpdateChildren(container, { flash: true }); container.update(); } } this._waitForChildren().then(() => { if (this._destroyed) { // Could not fully update after markup mutations, the markup-view was destroyed // while waiting for children. Bail out silently. return; } this._flashMutatedNodes(mutations); this.inspector.emit("markupmutation", mutations); // Since the htmlEditor is absolutely positioned, a mutation may change // the location in which it should be shown. if (this.htmlEditor) { this.htmlEditor.refresh(); } }); }, /** * React to display-change and scrollable-change events from the walker. These are * events that tell us when something of interest changed on a collection of nodes: * whether their display type changed, or whether they became scrollable. * * @param {Array} nodes * An array of nodeFronts */ _onWalkerNodeStatesChanged(nodes) { for (const node of nodes) { const container = this.getContainer(node); if (container) { container.update(); } } }, /** * Given a list of mutations returned by the mutation observer, flash the * corresponding containers to attract attention. */ _flashMutatedNodes(mutations) { const addedOrEditedContainers = new Set(); const removedContainers = new Set(); for (const { type, target, added, removed, newValue } of mutations) { const container = this.getContainer(target); if (container) { if (type === "characterData") { addedOrEditedContainers.add(container); } else if (type === "attributes" && newValue === null) { // Removed attributes should flash the entire node. // New or changed attributes will flash the attribute itself // in ElementEditor.flashAttribute. addedOrEditedContainers.add(container); } else if (type === "childList") { // If there has been removals, flash the parent if (removed.length) { removedContainers.add(container); } // If there has been additions, flash the nodes if their associated // container exist (so if their parent is expanded in the inspector). added.forEach(node => { const addedContainer = this.getContainer(node); if (addedContainer) { addedOrEditedContainers.add(addedContainer); // The node may be added as a result of an append, in which case // it will have been removed from another container first, but in // these cases we don't want to flash both the removal and the // addition removedContainers.delete(container); } }); } } } for (const container of removedContainers) { container.flashMutation(); } for (const container of addedOrEditedContainers) { container.flashMutation(); } }, /** * Make sure the given node's parents are expanded and the * node is scrolled on to screen. */ showNode(node, { centered = true, slotted, smoothScroll = false } = {}) { if (slotted && !this.hasContainer(node, slotted)) { throw new Error("Tried to show a slotted node not previously imported"); } else { this._ensureNodeImported(node); } return this._waitForChildren() .then(() => { if (this._destroyed) { return Promise.reject("markupview destroyed"); } return this._ensureVisible(node); }) .then(() => { const container = this.getContainer(node, slotted); scrollIntoViewIfNeeded(container.editor.elt, centered, smoothScroll); }, this._handleRejectionIfNotDestroyed); }, _ensureNodeImported(node) { let parent = node; this.importNode(node); while ((parent = this._getParentInTree(parent))) { this.importNode(parent); this.expandNode(parent); } }, /** * Expand the container's children. */ _expandContainer(container) { return this._updateChildren(container, { expand: true }).then(() => { if (this._destroyed) { // Could not expand the node, the markup-view was destroyed in the meantime. Just // silently give up. return; } container.setExpanded(true); }); }, /** * Expand the node's children. */ expandNode(node) { const container = this.getContainer(node); return this._expandContainer(container); }, /** * Expand the entire tree beneath a container. * * @param {MarkupContainer} container * The container to expand. */ _expandAll(container) { return this._expandContainer(container) .then(() => { let child = container.children.firstChild; const promises = []; while (child) { promises.push(this._expandAll(child.container)); child = child.nextSibling; } return Promise.all(promises); }) .catch(console.error); }, /** * Expand the entire tree beneath a node. * * @param {DOMNode} node * The node to expand, or null to start from the top. * @return {Promise} promise that resolves once all children are expanded. */ expandAll(node) { node = node || this._rootNode; return this._expandAll(this.getContainer(node)); }, /** * Collapse the node's children. */ collapseNode(node) { const container = this.getContainer(node); container.setExpanded(false); }, _collapseAll(container) { container.setExpanded(false); const children = container.getChildContainers() || []; children.forEach(child => this._collapseAll(child)); }, /** * Collapse the entire tree beneath a node. * * @param {DOMNode} node * The node to collapse. * @return {Promise} promise that resolves once all children are collapsed. */ collapseAll(node) { this._collapseAll(this.getContainer(node)); // collapseAll is synchronous, return a promise for consistency with expandAll. return Promise.resolve(); }, /** * Returns either the innerHTML or the outerHTML for a remote node. * * @param {NodeFront} node * The NodeFront to get the outerHTML / innerHTML for. * @param {Boolean} isOuter * If true, makes the function return the outerHTML, * otherwise the innerHTML. * @return {Promise} that will be resolved with the outerHTML / innerHTML. */ _getNodeHTML(node, isOuter) { let walkerPromise = null; if (isOuter) { walkerPromise = node.walkerFront.outerHTML(node); } else { walkerPromise = node.walkerFront.innerHTML(node); } return getLongString(walkerPromise); }, /** * Retrieve the outerHTML for a remote node. * * @param {NodeFront} node * The NodeFront to get the outerHTML for. * @return {Promise} that will be resolved with the outerHTML. */ getNodeOuterHTML(node) { return this._getNodeHTML(node, true); }, /** * Retrieve the innerHTML for a remote node. * * @param {NodeFront} node * The NodeFront to get the innerHTML for. * @return {Promise} that will be resolved with the innerHTML. */ getNodeInnerHTML(node) { return this._getNodeHTML(node); }, /** * Listen to mutations, expect a given node to be removed and try and select * the node that sits at the same place instead. * This is useful when changing the outerHTML or the tag name so that the * newly inserted node gets selected instead of the one that just got removed. */ reselectOnRemoved(removedNode, reason) { // Only allow one removed node reselection at a time, so that when there are // more than 1 request in parallel, the last one wins. this.cancelReselectOnRemoved(); // Get the removedNode index in its parent node to reselect the right node. const isRootElement = ["html", "svg"].includes( removedNode.tagName.toLowerCase() ); const oldContainer = this.getContainer(removedNode); const parentContainer = this.getContainer(removedNode.parentNode()); const childIndex = parentContainer .getChildContainers() .indexOf(oldContainer); const onMutations = (this._removedNodeObserver = mutations => { let isNodeRemovalMutation = false; for (const mutation of mutations) { const containsRemovedNode = mutation.removed && mutation.removed.some(n => n === removedNode); if ( mutation.type === "childList" && (containsRemovedNode || isRootElement) ) { isNodeRemovalMutation = true; break; } } if (!isNodeRemovalMutation) { return; } this.inspector.off("markupmutation", onMutations); this._removedNodeObserver = null; // Don't select the new node if the user has already changed the current // selection. if ( this.inspector.selection.nodeFront === parentContainer.node || (this.inspector.selection.nodeFront === removedNode && isRootElement) ) { const childContainers = parentContainer.getChildContainers(); if (childContainers?.[childIndex]) { const childContainer = childContainers[childIndex]; this._markContainerAsSelected(childContainer, reason); if (childContainer.hasChildren) { this.expandNode(childContainer.node); } this.emit("reselectedonremoved"); } } }); // Start listening for mutations until we find a childList change that has // removedNode removed. this.inspector.on("markupmutation", onMutations); }, /** * Make sure to stop listening for node removal markupmutations and not * reselect the corresponding node when that happens. * Useful when the outerHTML/tagname edition failed. */ cancelReselectOnRemoved() { if (this._removedNodeObserver) { this.inspector.off("markupmutation", this._removedNodeObserver); this._removedNodeObserver = null; this.emit("canceledreselectonremoved"); } }, /** * Replace the outerHTML of any node displayed in the inspector with * some other HTML code * * @param {NodeFront} node * Node which outerHTML will be replaced. * @param {String} newValue * The new outerHTML to set on the node. * @param {String} oldValue * The old outerHTML that will be used if the user undoes the update. * @return {Promise} that will resolve when the outer HTML has been updated. */ updateNodeOuterHTML(node, newValue) { const container = this.getContainer(node); if (!container) { return Promise.reject(); } // Changing the outerHTML removes the node which outerHTML was changed. // Listen to this removal to reselect the right node afterwards. this.reselectOnRemoved(node, "outerhtml"); return node.walkerFront.setOuterHTML(node, newValue).catch(() => { this.cancelReselectOnRemoved(); }); }, /** * Replace the innerHTML of any node displayed in the inspector with * some other HTML code * @param {Node} node * node which innerHTML will be replaced. * @param {String} newValue * The new innerHTML to set on the node. * @param {String} oldValue * The old innerHTML that will be used if the user undoes the update. * @return {Promise} that will resolve when the inner HTML has been updated. */ updateNodeInnerHTML(node, newValue, oldValue) { const container = this.getContainer(node); if (!container) { return Promise.reject(); } return new Promise((resolve, reject) => { container.undo.do( () => { node.walkerFront.setInnerHTML(node, newValue).then(resolve, reject); }, () => { node.walkerFront.setInnerHTML(node, oldValue); } ); }); }, /** * Insert adjacent HTML to any node displayed in the inspector. * * @param {NodeFront} node * The reference node. * @param {String} position * The position as specified for Element.insertAdjacentHTML * (i.e. "beforeBegin", "afterBegin", "beforeEnd", "afterEnd"). * @param {String} newValue * The adjacent HTML. * @return {Promise} that will resolve when the adjacent HTML has * been inserted. */ insertAdjacentHTMLToNode(node, position, value) { const container = this.getContainer(node); if (!container) { return Promise.reject(); } let injectedNodes = []; return new Promise((resolve, reject) => { container.undo.do( () => { // eslint-disable-next-line no-unsanitized/method node.walkerFront .insertAdjacentHTML(node, position, value) .then(nodeArray => { injectedNodes = nodeArray.nodes; return nodeArray; }) .then(resolve, reject); }, () => { node.walkerFront.removeNodes(injectedNodes); } ); }); }, /** * Open an editor in the UI to allow editing of a node's html. * * @param {NodeFront} node * The NodeFront to edit. */ beginEditingHTML(node) { // We use outer html for elements, but inner html for fragments. const isOuter = node.nodeType == nodeConstants.ELEMENT_NODE; const html = isOuter ? this.getNodeOuterHTML(node) : this.getNodeInnerHTML(node); html.then(oldValue => { const container = this.getContainer(node); if (!container) { return; } // Load load and create HTML Editor as it is rarely used and fetch complex deps if (!this.htmlEditor) { const HTMLEditor = require("resource://devtools/client/inspector/markup/views/html-editor.js"); this.htmlEditor = new HTMLEditor(this.doc); } this.htmlEditor.show(container.tagLine, oldValue); const start = this.telemetry.msSystemNow(); this.htmlEditor.once("popuphidden", (commit, value) => { // Need to focus the element instead of the frame / window // in order to give keyboard focus back to doc (from editor). this.doc.documentElement.focus(); if (commit) { if (isOuter) { this.updateNodeOuterHTML(node, value, oldValue); } else { this.updateNodeInnerHTML(node, value, oldValue); } } const end = this.telemetry.msSystemNow(); this.telemetry.recordEvent("edit_html", "inspector", null, { made_changes: commit, time_open: end - start, }); }); this.emit("begin-editing"); }); }, /** * Expand or collapse the given node. * * @param {NodeFront} node * The NodeFront to update. * @param {Boolean} expanded * Whether the node should be expanded/collapsed. * @param {Boolean} applyToDescendants * Whether all descendants should also be expanded/collapsed */ setNodeExpanded(node, expanded, applyToDescendants) { if (expanded) { if (applyToDescendants) { this.expandAll(node); } else { this.expandNode(node); } } else if (applyToDescendants) { this.collapseAll(node); } else { this.collapseNode(node); } }, /** * Mark the given node selected, and update the inspector.selection * object's NodeFront to keep consistent state between UI and selection. * * @param {NodeFront} aNode * The NodeFront to mark as selected. * @param {String} reason * The reason for marking the node as selected. * @return {Boolean} False if the node is already marked as selected, true * otherwise. */ markNodeAsSelected(node, reason = "nodeselected") { const container = this.getContainer(node); return this._markContainerAsSelected(container); }, _markContainerAsSelected(container, reason) { if (!container || this._selectedContainer === container) { return false; } const { node } = container; // Un-select and remove focus from the previous container. if (this._selectedContainer) { this._selectedContainer.selected = false; this._selectedContainer.clearFocus(); } // Select the new container. this._selectedContainer = container; if (node) { this._selectedContainer.selected = true; } // Change the current selection if needed. if (!this._isContainerSelected(this._selectedContainer)) { const isSlotted = container.isSlotted(); this.inspector.selection.setNodeFront(node, { reason, isSlotted }); } return true; }, /** * Make sure that every ancestor of the selection are updated * and included in the list of visible children. */ _ensureVisible(node) { while (node) { const container = this.getContainer(node); const parent = this._getParentInTree(node); if (!container.elt.parentNode) { const parentContainer = this.getContainer(parent); if (parentContainer) { this._forceUpdateChildren(parentContainer, { expand: true }); } } node = parent; } return this._waitForChildren(); }, /** * Unmark selected node (no node selected). */ unmarkSelectedNode() { if (this._selectedContainer) { this._selectedContainer.selected = false; this._selectedContainer = null; } }, /** * Check if the current selection is a descendent of the container. * if so, make sure it's among the visible set for the container, * and set the dirty flag if needed. * * @return The node that should be made visible, if any. */ _checkSelectionVisible(container) { let centered = null; let node = this.inspector.selection.nodeFront; while (node) { if (this._getParentInTree(node) === container.node) { centered = node; break; } node = this._getParentInTree(node); } return centered; }, async _forceUpdateChildren(container, options = {}) { const { flash, updateLevel, expand } = options; // Set childrenDirty to true to force fetching new children. container.childrenDirty = true; // Update the children to take care of changes in the markup view DOM await this._updateChildren(container, { expand, flash }); // The markup view may have been destroyed in the meantime if (this._destroyed) { return; } if (updateLevel) { // Update container (and its subtree) DOM tree depth level for // accessibility where necessary. container.updateLevel(); } }, /** * Make sure all children of the given container's node are * imported and attached to the container in the right order. * * Children need to be updated only in the following circumstances: * a) We just imported this node and have never seen its children. * container.childrenDirty will be set by importNode in this case. * b) We received a childList mutation on the node. * container.childrenDirty will be set in that case too. * c) We have changed the selection, and the path to that selection * wasn't loaded in a previous children request (because we only * grab a subset). * container.childrenDirty should be set in that case too! * * @param {MarkupContainer} container * The markup container whose children need updating * @param {Object} options * Options are {expand:boolean,flash:boolean} * @return {Promise} that will be resolved when the children are ready * (which may be immediately). */ _updateChildren(container, options) { // Slotted containers do not display any children. if (container.isSlotted()) { return Promise.resolve(container); } const expand = options?.expand; const flash = options?.flash; container.hasChildren = container.node.hasChildren; // Accessibility should either ignore empty children or semantically // consider them a group. container.setChildrenRole(); if (!this._queuedChildUpdates) { this._queuedChildUpdates = new Map(); } if (this._queuedChildUpdates.has(container)) { return this._queuedChildUpdates.get(container); } if (!container.childrenDirty) { return Promise.resolve(container); } // Before bailing out for other conditions, check if the unavailable // children badge needs updating (Bug 1776250). if ( typeof container?.editor?.hasUnavailableChildren == "function" && container.editor.hasUnavailableChildren() != container.node.childrenUnavailable ) { container.update(); } if ( container.inlineTextChild && container.inlineTextChild != container.node.inlineTextChild ) { // This container was doing double duty as a container for a single // text child, back that out. this._containers.delete(container.inlineTextChild); container.clearInlineTextChild(); if (container.hasChildren && container.selected) { container.setExpanded(true); } } if (container.node.inlineTextChild) { container.setExpanded(false); // this container will do double duty as the container for the single // text child. while (container.children.firstChild) { container.children.firstChild.remove(); } container.setInlineTextChild(container.node.inlineTextChild); this.setContainer(container.node.inlineTextChild, container); container.childrenDirty = false; return Promise.resolve(container); } if (!container.hasChildren) { while (container.children.firstChild) { container.children.firstChild.remove(); } container.childrenDirty = false; container.setExpanded(false); return Promise.resolve(container); } // If we're not expanded (or asked to update anyway), we're done for // now. Note that this will leave the childrenDirty flag set, so when // expanded we'll refresh the child list. if (!(container.expanded || expand)) { return Promise.resolve(container); } // We're going to issue a children request, make sure it includes the // centered node. const centered = this._checkSelectionVisible(container); // Children aren't updated yet, but clear the childrenDirty flag anyway. // If the dirty flag is re-set while we're fetching we'll need to fetch // again. container.childrenDirty = false; const isShadowHost = container.node.isShadowHost; const updatePromise = this._getVisibleChildren(container, centered) .then(children => { if (!this._containers) { return Promise.reject("markup view destroyed"); } this._queuedChildUpdates.delete(container); // If children are dirty, we got a change notification for this node // while the request was in progress, we need to do it again. if (container.childrenDirty) { return this._updateChildren(container, { expand: centered || expand, }); } const fragment = this.doc.createDocumentFragment(); for (const child of children.nodes) { const slotted = !isShadowHost && child.isDirectShadowHostChild; const childContainer = this.importNode(child, flash, slotted); fragment.appendChild(childContainer.elt); } while (container.children.firstChild) { container.children.firstChild.remove(); } if (!children.hasFirst) { const topItem = this.buildMoreNodesButtonMarkup(container); fragment.insertBefore(topItem, fragment.firstChild); } if (!children.hasLast) { const bottomItem = this.buildMoreNodesButtonMarkup(container); fragment.appendChild(bottomItem); } container.children.appendChild(fragment); return container; }) .catch(this._handleRejectionIfNotDestroyed); this._queuedChildUpdates.set(container, updatePromise); return updatePromise; }, buildMoreNodesButtonMarkup(container) { const elt = this.doc.createElement("li"); elt.classList.add("more-nodes", "devtools-class-comment"); const label = this.doc.createElement("span"); label.textContent = INSPECTOR_L10N.getStr("markupView.more.showing"); elt.appendChild(label); const button = this.doc.createElement("button"); button.setAttribute("href", "#"); const showAllString = PluralForm.get( container.node.numChildren, INSPECTOR_L10N.getStr("markupView.more.showAll2") ); button.textContent = showAllString.replace( "#1", container.node.numChildren ); elt.appendChild(button); button.addEventListener("click", () => { container.maxChildren = -1; this._forceUpdateChildren(container); }); return elt; }, _waitForChildren() { if (!this._queuedChildUpdates) { return Promise.resolve(undefined); } return Promise.all([...this._queuedChildUpdates.values()]); }, /** * Return a list of the children to display for this container. */ async _getVisibleChildren(container, centered) { let maxChildren = container.maxChildren || this.maxChildren; if (maxChildren == -1) { maxChildren = undefined; } // We have to use node's walker and not a top level walker // as for fission frames, we are going to have multiple walkers const inspectorFront = await container.node.targetFront.getFront( "inspector" ); return inspectorFront.walker.children(container.node, { maxNodes: maxChildren, center: centered, }); }, /** * The parent of a given node as rendered in the markup view is not necessarily * node.parentNode(). For instance, shadow roots don't have a parentNode, but a host * element. However they are represented as parent and children in the markup view. * * Use this method when you are interested in the parent of a node from the perspective * of the markup-view tree, and not from the perspective of the actual DOM. */ _getParentInTree(node) { const parent = node.parentOrHost(); if (!parent) { return null; } // If the parent node belongs to a different target while the node's target is the // one selected by the user in the iframe picker, we don't want to go further up. if ( node.targetFront !== parent.targetFront && node.targetFront == this.inspector.commands.targetCommand.selectedTargetFront ) { return null; } return parent; }, /** * Tear down the markup panel. */ destroy() { if (this._destroyed) { return; } this._destroyed = true; this._hoveredContainer = null; if (this._contextMenu) { this._contextMenu.destroy(); this._contextMenu = null; } if (this._eventDetailsTooltip) { this._eventDetailsTooltip.destroy(); this._eventDetailsTooltip = null; } if (this.htmlEditor) { this.htmlEditor.destroy(); this.htmlEditor = null; } if (this.imagePreviewTooltip) { this.imagePreviewTooltip.destroy(); this.imagePreviewTooltip = null; } if (this._undo) { this._undo.destroy(); this._undo = null; } if (this._shortcuts) { this._shortcuts.destroy(); this._shortcuts = null; } this.popup.destroy(); this.popup = null; this._selectedContainer = null; this._elt.removeEventListener("blur", this._onBlur, true); this._elt.removeEventListener("click", this._onMouseClick); this._elt.removeEventListener("contextmenu", this._onContextMenu); this._elt.removeEventListener("mousemove", this._onMouseMove); this._elt.removeEventListener("mouseout", this._onMouseOut); this._frame.removeEventListener("focus", this._onFocus); this._unsubscribeFromToolboxStore(); this.inspector.selection.off("new-node-front", this._onNewSelection); this.resourceCommand.unwatchResources( [this.resourceCommand.TYPES.ROOT_NODE], { onAvailable: this._onResourceAvailable } ); this.targetCommand.unwatchTargets({ types: [this.targetCommand.TYPES.FRAME], onAvailable: this._onTargetAvailable, onDestroyed: this._onTargetDestroyed, }); this.inspector.toolbox.nodePicker.off( "picker-node-hovered", this._onToolboxPickerHover ); this.inspector.toolbox.nodePicker.off( "picker-node-canceled", this._onToolboxPickerCanceled ); this.inspector.highlighters.off( "highlighter-shown", this.onHighlighterShown ); this.inspector.highlighters.off( "highlighter-hidden", this.onHighlighterHidden ); this.win.removeEventListener("copy", this._onCopy); this.win.removeEventListener("mouseup", this._onMouseUp); this._walkerEventListener.destroy(); this._walkerEventListener = null; this._prefObserver.off( ATTR_COLLAPSE_ENABLED_PREF, this._onCollapseAttributesPrefChange ); this._prefObserver.off( ATTR_COLLAPSE_LENGTH_PREF, this._onCollapseAttributesPrefChange ); this._prefObserver.destroy(); for (const [, container] of this._containers) { container.destroy(); } this._containers = null; this._elt.innerHTML = ""; this._elt = null; this.controllerWindow = null; this.doc = null; this.highlighters = null; this.walker = null; this.resourceCommand = null; this.win = null; this._lastDropTarget = null; this._lastDragTarget = null; }, /** * Find the closest element with class tag-line. These are used to indicate * drag and drop targets. * * @param {DOMNode} el * @return {DOMNode} */ findClosestDragDropTarget(el) { return el.classList.contains("tag-line") ? el : el.querySelector(".tag-line") || el.closest(".tag-line"); }, /** * Takes an element as it's only argument and marks the element * as the drop target */ indicateDropTarget(el) { if (this._lastDropTarget) { this._lastDropTarget.classList.remove("drop-target"); } if (!el) { return; } const target = this.findClosestDragDropTarget(el); if (target) { target.classList.add("drop-target"); this._lastDropTarget = target; } }, /** * Takes an element to mark it as indicator of dragging target's initial place */ indicateDragTarget(el) { if (this._lastDragTarget) { this._lastDragTarget.classList.remove("drag-target"); } if (!el) { return; } const target = this.findClosestDragDropTarget(el); if (target) { target.classList.add("drag-target"); this._lastDragTarget = target; } }, /** * Used to get the nodes required to modify the markup after dragging the * element (parent/nextSibling). */ get dropTargetNodes() { const target = this._lastDropTarget; if (!target) { return null; } let parent, nextSibling; if ( target.previousElementSibling && target.previousElementSibling.nodeName.toLowerCase() === "ul" ) { parent = target.parentNode.container.node; nextSibling = null; } else { parent = target.parentNode.container.node.parentNode(); nextSibling = target.parentNode.container.node; } if (nextSibling) { while ( nextSibling.isMarkerPseudoElement || nextSibling.isBeforePseudoElement ) { nextSibling = this.getContainer(nextSibling).elt.nextSibling.container.node; } if (nextSibling.isAfterPseudoElement) { parent = target.parentNode.container.node.parentNode(); nextSibling = null; } } if (parent.nodeType !== nodeConstants.ELEMENT_NODE) { return null; } return { parent, nextSibling }; }, }; /** * Copy the content of a longString containing HTML code to the clipboard. * The string is retrieved, and possibly beautified if the user has the right pref set and * then placed in the clipboard. * * @param {Promise} longStringActorPromise * The promise expected to resolve a LongStringActor instance */ async function copyLongHTMLString(longStringActorPromise) { let string = await getLongString(longStringActorPromise); if (Services.prefs.getBoolPref(BEAUTIFY_HTML_ON_COPY_PREF)) { const { indentUnit, indentWithTabs } = getTabPrefs(); string = beautify.html(string, { // eslint-disable-next-line camelcase preserve_newlines: false, // eslint-disable-next-line camelcase indent_size: indentWithTabs ? 1 : indentUnit, // eslint-disable-next-line camelcase indent_char: indentWithTabs ? "\t" : " ", unformatted: [], }); } clipboardHelper.copyString(string); } /** * Map a number from one range to another. */ function map(value, oldMin, oldMax, newMin, newMax) { const ratio = oldMax - oldMin; if (ratio == 0) { return value; } return newMin + (newMax - newMin) * ((value - oldMin) / ratio); } module.exports = MarkupView;