summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/markup/markup.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/inspector/markup/markup.js2682
1 files changed, 2682 insertions, 0 deletions
diff --git a/devtools/client/inspector/markup/markup.js b/devtools/client/inspector/markup/markup.js
new file mode 100644
index 0000000000..849af6c155
--- /dev/null
+++ b/devtools/client/inspector/markup/markup.js
@@ -0,0 +1,2682 @@
+/* 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, {
+ "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;
+ },
+
+ 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() {
+ 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) {
+ badge.classList.toggle("active", eventName == "highlighter-shown");
+ }
+
+ // 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(
+ "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("<!--" + comment + "-->");
+ });
+ 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);
+ });
+ })
+ .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;
+ }
+
+ 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 <html> 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;