/* 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() {
if (!
this.imagePreviewTooltip) {
return;
}
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
.getIdrefNode(nodeFront, CSS.escape(link))
.then(node => {
if (!node) {
this.emitForTests(
"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() {},
_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} node
* The NodeFront to mark as selected.
* @return {Boolean} False if the node is already marked as selected, true
* otherwise.
*/
markNodeAsSelected(node) {
const container =
this.getContainer(node);
--> --------------------
--> maximum size reached
--> --------------------