summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/shared/highlighters-overlay.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--devtools/client/inspector/shared/highlighters-overlay.js2007
1 files changed, 2007 insertions, 0 deletions
diff --git a/devtools/client/inspector/shared/highlighters-overlay.js b/devtools/client/inspector/shared/highlighters-overlay.js
new file mode 100644
index 0000000000..d67c0a05ea
--- /dev/null
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -0,0 +1,2007 @@
+/* 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 {
+ safeAsyncMethod,
+} = require("resource://devtools/shared/async-utils.js");
+const EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const WalkerEventListener = require("resource://devtools/client/inspector/shared/walker-event-listener.js");
+const {
+ VIEW_NODE_VALUE_TYPE,
+ VIEW_NODE_SHAPE_POINT_TYPE,
+} = require("resource://devtools/client/inspector/shared/node-types.js");
+
+loader.lazyRequireGetter(
+ this,
+ "parseURL",
+ "resource://devtools/client/shared/source-utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "asyncStorage",
+ "resource://devtools/shared/async-storage.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "gridsReducer",
+ "resource://devtools/client/inspector/grids/reducers/grids.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "highlighterSettingsReducer",
+ "resource://devtools/client/inspector/grids/reducers/highlighter-settings.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "flexboxReducer",
+ "resource://devtools/client/inspector/flexbox/reducers/flexbox.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "deepEqual",
+ "resource://devtools/shared/DevToolsUtils.js",
+ true
+);
+loader.lazyGetter(this, "HighlightersBundle", () => {
+ return new Localization(["devtools/shared/highlighters.ftl"], true);
+});
+
+const DEFAULT_HIGHLIGHTER_COLOR = "#9400FF";
+const SUBGRID_PARENT_ALPHA = 0.5;
+
+const TYPES = {
+ BOXMODEL: "BoxModelHighlighter",
+ FLEXBOX: "FlexboxHighlighter",
+ GEOMETRY: "GeometryEditorHighlighter",
+ GRID: "CssGridHighlighter",
+ SHAPES: "ShapesHighlighter",
+ SELECTOR: "SelectorHighlighter",
+ TRANSFORM: "CssTransformHighlighter",
+};
+
+/**
+ * While refactoring to an abstracted way to show and hide highlighters,
+ * we did not update all tests and code paths which listen for exact events.
+ *
+ * When we show or hide highlighters we reference this mapping to
+ * emit events that consumers may be listening to.
+ *
+ * This list should go away as we incrementally rewrite tests to use
+ * abstract event names with data payloads indicating the highlighter.
+ *
+ * DO NOT OPTIMIZE THIS MAPPING AS CONCATENATED SUBSTRINGS!
+ * It makes it difficult to do project-wide searches for exact matches.
+ */
+const HIGHLIGHTER_EVENTS = {
+ [TYPES.GRID]: {
+ shown: "grid-highlighter-shown",
+ hidden: "grid-highlighter-hidden",
+ },
+ [TYPES.GEOMETRY]: {
+ shown: "geometry-editor-highlighter-shown",
+ hidden: "geometry-editor-highlighter-hidden",
+ },
+ [TYPES.SHAPES]: {
+ shown: "shapes-highlighter-shown",
+ hidden: "shapes-highlighter-hidden",
+ },
+ [TYPES.TRANSFORM]: {
+ shown: "css-transform-highlighter-shown",
+ hidden: "css-transform-highlighter-hidden",
+ },
+};
+
+// Tool IDs mapped by highlighter type. Used to log telemetry for opening & closing tools.
+const TELEMETRY_TOOL_IDS = {
+ [TYPES.FLEXBOX]: "FLEXBOX_HIGHLIGHTER",
+ [TYPES.GRID]: "GRID_HIGHLIGHTER",
+};
+
+// Scalars mapped by highlighter type. Used to log telemetry about highlighter triggers.
+const TELEMETRY_SCALARS = {
+ [TYPES.FLEXBOX]: {
+ layout: "devtools.layout.flexboxhighlighter.opened",
+ markup: "devtools.markup.flexboxhighlighter.opened",
+ rule: "devtools.rules.flexboxhighlighter.opened",
+ },
+
+ [TYPES.GRID]: {
+ grid: "devtools.grid.gridinspector.opened",
+ markup: "devtools.markup.gridinspector.opened",
+ rule: "devtools.rules.gridinspector.opened",
+ },
+};
+
+/**
+ * HighlightersOverlay manages the visibility of highlighters in the Inspector.
+ */
+class HighlightersOverlay {
+ /**
+ * @param {Inspector} inspector
+ * Inspector toolbox panel.
+ */
+ constructor(inspector) {
+ this.inspector = inspector;
+ this.store = this.inspector.store;
+
+ this.telemetry = this.inspector.telemetry;
+ this.maxGridHighlighters = Services.prefs.getIntPref(
+ "devtools.gridinspector.maxHighlighters"
+ );
+
+ // Map of active highlighter types to objects with the highlighted nodeFront and the
+ // highlighter instance. Ex: "BoxModelHighlighter" => { nodeFront, highlighter }
+ // It will fully replace this.highlighters when all highlighter consumers are updated
+ // to use it as the single source of truth for which highlighters are visible.
+ this._activeHighlighters = new Map();
+ // Map of highlighter types to symbols. Showing highlighters is an async operation,
+ // until it doesn't complete, this map will be populated with the requested type and
+ // a unique symbol identifying that request. Once completed, the entry is removed.
+ this._pendingHighlighters = new Map();
+ // Map of highlighter types to objects with metadata used to restore active
+ // highlighters after a page reload.
+ this._restorableHighlighters = new Map();
+ // Collection of instantiated highlighter actors like FlexboxHighlighter,
+ // ShapesHighlighter and GeometryEditorHighlighter.
+ this.highlighters = {};
+ // Map of grid container node to an object with the grid highlighter instance
+ // and, if the node is a subgrid, the parent grid node and parent grid highlighter.
+ // Ex: {NodeFront} => {
+ // highlighter: {CustomHighlighterFront},
+ // parentGridNode: {NodeFront|null},
+ // parentGridHighlighter: {CustomHighlighterFront|null}
+ // }
+ this.gridHighlighters = new Map();
+ // Collection of instantiated in-context editors, like ShapesInContextEditor, which
+ // behave like highlighters but with added editing capabilities that need to map value
+ // changes to properties in the Rule view.
+ this.editors = {};
+
+ // Highlighter state.
+ this.state = {
+ // Map of grid container NodeFront to the their stored grid options
+ // Used to restore grid highlighters on reload (should be migrated to
+ // _restorableHighlighters in Bug 1572652).
+ grids: new Map(),
+ // Shape Path Editor highlighter options.
+ // Used as a cache for the latest configuration when showing the highlighter.
+ // It is reused and augmented when hovering coordinates in the Rules view which
+ // mark the corresponding points in the highlighter overlay.
+ shapes: {},
+ };
+
+ // NodeFront of element that is highlighted by the geometry editor.
+ this.geometryEditorHighlighterShown = null;
+ // Name of the highlighter shown on mouse hover.
+ this.hoveredHighlighterShown = null;
+ // NodeFront of the shape that is highlighted
+ this.shapesHighlighterShown = null;
+
+ this.onClick = this.onClick.bind(this);
+ this.onDisplayChange = this.onDisplayChange.bind(this);
+ this.onMarkupMutation = this.onMarkupMutation.bind(this);
+ this._onResourceAvailable = this._onResourceAvailable.bind(this);
+
+ this.onMouseMove = this.onMouseMove.bind(this);
+ this.onMouseOut = this.onMouseOut.bind(this);
+ this.hideAllHighlighters = this.hideAllHighlighters.bind(this);
+ this.hideFlexboxHighlighter = this.hideFlexboxHighlighter.bind(this);
+ this.hideGridHighlighter = this.hideGridHighlighter.bind(this);
+ this.hideShapesHighlighter = this.hideShapesHighlighter.bind(this);
+ this.showFlexboxHighlighter = this.showFlexboxHighlighter.bind(this);
+ this.showGridHighlighter = this.showGridHighlighter.bind(this);
+ this.showShapesHighlighter = this.showShapesHighlighter.bind(this);
+ this._handleRejection = this._handleRejection.bind(this);
+ this.onShapesHighlighterShown = this.onShapesHighlighterShown.bind(this);
+ this.onShapesHighlighterHidden = this.onShapesHighlighterHidden.bind(this);
+
+ // Catch unexpected errors from async functions if the manager has been destroyed.
+ this.hideHighlighterType = safeAsyncMethod(
+ this.hideHighlighterType.bind(this),
+ () => this.destroyed
+ );
+ this.showHighlighterTypeForNode = safeAsyncMethod(
+ this.showHighlighterTypeForNode.bind(this),
+ () => this.destroyed
+ );
+ this.showGridHighlighter = safeAsyncMethod(
+ this.showGridHighlighter.bind(this),
+ () => this.destroyed
+ );
+ this.restoreState = safeAsyncMethod(
+ this.restoreState.bind(this),
+ () => this.destroyed
+ );
+
+ // Add inspector events, not specific to a given view.
+ this.inspector.on("markupmutation", this.onMarkupMutation);
+
+ this.resourceCommand = this.inspector.toolbox.resourceCommand;
+ this.resourceCommand.watchResources(
+ [this.resourceCommand.TYPES.ROOT_NODE],
+ { onAvailable: this._onResourceAvailable }
+ );
+
+ this.walkerEventListener = new WalkerEventListener(this.inspector, {
+ "display-change": this.onDisplayChange,
+ });
+
+ if (this.toolbox.win.matchMedia("(prefers-reduced-motion)").matches) {
+ this._showSimpleHighlightersMessage();
+ }
+
+ EventEmitter.decorate(this);
+ }
+
+ get inspectorFront() {
+ return this.inspector.inspectorFront;
+ }
+
+ get target() {
+ return this.inspector.currentTarget;
+ }
+
+ get toolbox() {
+ return this.inspector.toolbox;
+ }
+
+ // FIXME: Shim for HighlightersOverlay.parentGridHighlighters
+ // Remove after updating tests to stop accessing this map directly. Bug 1683153
+ get parentGridHighlighters() {
+ return Array.from(this.gridHighlighters.values()).reduce((map, value) => {
+ const { parentGridNode, parentGridHighlighter } = value;
+ if (parentGridNode) {
+ map.set(parentGridNode, parentGridHighlighter);
+ }
+
+ return map;
+ }, new Map());
+ }
+
+ /**
+ * Optionally run some operations right after showing a highlighter of a given type,
+ * but before notifying consumers by emitting the "highlighter-shown" event.
+ *
+ * This is a chance to run some non-essential operations like: logging telemetry data,
+ * storing metadata about the highlighter to enable restoring it after refresh, etc.
+ *
+ * @param {String} type
+ * Highlighter type shown.
+ * @param {NodeFront} nodeFront
+ * Node front of the element that was highlighted.
+ * @param {Options} options
+ * Optional object with options passed to the highlighter.
+ */
+ _afterShowHighlighterTypeForNode(type, nodeFront, options) {
+ switch (type) {
+ // Log telemetry for showing the flexbox and grid highlighters.
+ case TYPES.FLEXBOX:
+ case TYPES.GRID:
+ const toolID = TELEMETRY_TOOL_IDS[type];
+ if (toolID) {
+ this.telemetry.toolOpened(toolID, this);
+ }
+
+ const scalar = TELEMETRY_SCALARS[type]?.[options?.trigger];
+ if (scalar) {
+ this.telemetry.scalarAdd(scalar, 1);
+ }
+
+ break;
+ }
+
+ // Set metadata necessary to restore the active highlighter upon page refresh.
+ if (type === TYPES.FLEXBOX) {
+ const { url } = this.target;
+ const selectors = [...this.inspector.selectionCssSelectors];
+
+ this._restorableHighlighters.set(type, {
+ options,
+ selectors,
+ type,
+ url,
+ });
+ }
+ }
+
+ /**
+ * Optionally run some operations before showing a highlighter of a given type.
+ *
+ * Depending its type, before showing a new instance of a highlighter, we may do extra
+ * operations, like hiding another visible highlighter, or preventing the show
+ * operation, for example due to a duplicate call with the same arguments.
+ *
+ * Returns a promise that resovles with a boolean indicating whether to skip showing
+ * the highlighter with these arguments.
+ *
+ * @param {String} type
+ * Highlighter type to show.
+ * @param {NodeFront} nodeFront
+ * Node front of the element to be highlighted.
+ * @param {Options} options
+ * Optional object with options to pass to the highlighter.
+ * @return {Promise}
+ */
+ async _beforeShowHighlighterTypeForNode(type, nodeFront, options) {
+ // Get the data associated with the visible highlighter of this type, if any.
+ const {
+ highlighter: activeHighlighter,
+ nodeFront: activeNodeFront,
+ options: activeOptions,
+ timer: activeTimer,
+ } = this.getDataForActiveHighlighter(type);
+
+ // There isn't an active highlighter of this type. Early return, proceed with showing.
+ if (!activeHighlighter) {
+ return false;
+ }
+
+ // Whether conditions are met to skip showing the highlighter (ex: duplicate calls).
+ let skipShow = false;
+
+ // Clear any autohide timer associated with this highlighter type.
+ // This clears any existing timer for duplicate calls to show() if:
+ // - called with different options.duration
+ // - called once with options.duration, then without (see deepEqual() above)
+ clearTimeout(activeTimer);
+
+ switch (type) {
+ // Hide the visible selector highlighter if called for the same node,
+ // but with a different selector.
+ case TYPES.SELECTOR:
+ if (
+ nodeFront === activeNodeFront &&
+ options?.selector !== activeOptions?.selector
+ ) {
+ await this.hideHighlighterType(TYPES.SELECTOR);
+ }
+ break;
+
+ // For others, hide the existing highlighter before showing it for a different node.
+ // Else, if the node is the same and options are the same, skip a duplicate call.
+ // Duplicate calls to show the highlighter for the same node are allowed
+ // if the options are different (for example, when scheduling autohide).
+ default:
+ if (nodeFront !== activeNodeFront) {
+ await this.hideHighlighterType(type);
+ } else if (deepEqual(options, activeOptions)) {
+ skipShow = true;
+ }
+ }
+
+ return skipShow;
+ }
+
+ /**
+ * Optionally run some operations before hiding a highlighter of a given type.
+ * Runs only if a highlighter of that type exists.
+ *
+ * @param {String} type
+ * highlighter type
+ * @return {Promise}
+ */
+ _beforeHideHighlighterType(type) {
+ switch (type) {
+ // Log telemetry for hiding the flexbox and grid highlighters.
+ case TYPES.FLEXBOX:
+ case TYPES.GRID:
+ const toolID = TELEMETRY_TOOL_IDS[type];
+ const conditions = {
+ [TYPES.FLEXBOX]: () => {
+ // always stop the timer when the flexbox highlighter is about to be hidden.
+ return true;
+ },
+ [TYPES.GRID]: () => {
+ // stop the timer only once the last grid highlighter is about to be hidden.
+ return this.gridHighlighters.size === 1;
+ },
+ };
+
+ if (toolID && conditions[type].call(this)) {
+ this.telemetry.toolClosed(toolID, this);
+ }
+
+ break;
+ }
+ }
+
+ /**
+ * Get the maximum number of possible active highlighter instances of a given type.
+ *
+ * @param {String} type
+ * Highlighter type
+ * @return {Number}
+ * Default 1
+ */
+ _getMaxActiveHighlighters(type) {
+ let max;
+
+ switch (type) {
+ // Grid highligthters are special (there is a parent-child relationship between
+ // subgrid and parent grid) so we suppport multiple visible instances.
+ // Grid highlighters are performance-intensive and this limit is somewhat arbitrary
+ // to guard against performance degradation.
+ case TYPES.GRID:
+ max = this.maxGridHighlighters;
+ break;
+ // By default, for all other highlighter types, only one instance may visible.
+ // Before showing a new highlighter, any other instance will be hidden.
+ default:
+ max = 1;
+ }
+
+ return max;
+ }
+
+ /**
+ * Get a highlighter instance of the given type for the given node front.
+ *
+ * @param {String} type
+ * Highlighter type.
+ * @param {NodeFront} nodeFront
+ * Node front of the element to be highlighted with the requested highlighter.
+ * @return {Promise}
+ * Promise which resolves with a highlighter instance
+ */
+ async _getHighlighterTypeForNode(type, nodeFront) {
+ const { inspectorFront } = nodeFront;
+ const max = this._getMaxActiveHighlighters(type);
+ let highlighter;
+
+ // If only one highlighter instance may be visible, get a highlighter front
+ // and cache it to return it on future requests.
+ // Otherwise, return a new highlighter front every time and clean-up manually.
+ if (max === 1) {
+ highlighter = await inspectorFront.getOrCreateHighlighterByType(type);
+ } else {
+ highlighter = await inspectorFront.getHighlighterByType(type);
+ }
+
+ return highlighter;
+ }
+
+ /**
+ * Get the currently active highlighter of a given type.
+ *
+ * @param {String} type
+ * Highlighter type.
+ * @return {Highlighter|null}
+ * Highlighter instance
+ * or null if no highlighter of that type is active.
+ */
+ getActiveHighlighter(type) {
+ if (!this._activeHighlighters.has(type)) {
+ return null;
+ }
+
+ const { highlighter } = this._activeHighlighters.get(type);
+ return highlighter;
+ }
+
+ /**
+ * Get an object with data associated with the active highlighter of a given type.
+ * This data object contains:
+ * - nodeFront: NodeFront of the highlighted node
+ * - highlighter: Highlighter instance
+ * - options: Configuration options passed to the highlighter
+ * - timer: (Optional) index of timer set with setTimout() to autohide the highlighter
+ * Returns an empty object if a highlighter of the given type is not active.
+ *
+ * @param {String} type
+ * Highlighter type.
+ * @return {Object}
+ */
+ getDataForActiveHighlighter(type) {
+ if (!this._activeHighlighters.has(type)) {
+ return {};
+ }
+
+ return this._activeHighlighters.get(type);
+ }
+
+ /**
+ * Get the configuration options of the active highlighter of a given type.
+ *
+ * @param {String} type
+ * Highlighter type.
+ * @return {Object}
+ */
+ getOptionsForActiveHighlighter(type) {
+ const { options } = this.getDataForActiveHighlighter(type);
+ return options;
+ }
+
+ /**
+ * Get the node front highlighted by a given highlighter type.
+ *
+ * @param {String} type
+ * Highlighter type.
+ * @return {NodeFront|null}
+ * Node front of the element currently being highlighted
+ * or null if no highlighter of that type is active.
+ */
+ getNodeForActiveHighlighter(type) {
+ if (!this._activeHighlighters.has(type)) {
+ return null;
+ }
+
+ const { nodeFront } = this._activeHighlighters.get(type);
+ return nodeFront;
+ }
+
+ /**
+ * Highlight a given node front with a given type of highlighter.
+ *
+ * Highlighters are shown for one node at a time. Before showing the same highlighter
+ * type on another node, it will first be hidden from the previously highlighted node.
+ * In pages with frames running in different processes, this ensures highlighters from
+ * other frames do not stay visible.
+ *
+ * @param {String} type
+ * Highlighter type to show.
+ * @param {NodeFront} nodeFront
+ * Node front of the element to be highlighted.
+ * @param {Options} options
+ * Optional object with options to pass to the highlighter.
+ * @return {Promise}
+ */
+ async showHighlighterTypeForNode(type, nodeFront, options) {
+ const promise = this._beforeShowHighlighterTypeForNode(
+ type,
+ nodeFront,
+ options
+ );
+
+ // Set a pending highlighter in order to detect if, while we were awaiting, there was
+ // a more recent request to highlight a node with the same type, or a request to hide
+ // the highlighter. Then we will abort this one in favor of the newer one.
+ // This needs to be done before the 'await' in order to be synchronous, but after
+ // calling _beforeShowHighlighterTypeForNode, since it can call hideHighlighterType.
+ const id = Symbol();
+ this._pendingHighlighters.set(type, id);
+ const skipShow = await promise;
+
+ if (this._pendingHighlighters.get(type) !== id) {
+ return;
+ } else if (skipShow || nodeFront.isDestroyed()) {
+ this._pendingHighlighters.delete(type);
+ return;
+ }
+
+ const highlighter = await this._getHighlighterTypeForNode(type, nodeFront);
+
+ if (this._pendingHighlighters.get(type) !== id) {
+ return;
+ }
+ this._pendingHighlighters.delete(type);
+
+ // Set a timer to automatically hide the highlighter if a duration is provided.
+ const timer = this.scheduleAutoHideHighlighterType(type, options?.duration);
+ // TODO: support case for multiple highlighter instances (ex: multiple grids)
+ this._activeHighlighters.set(type, {
+ nodeFront,
+ highlighter,
+ options,
+ timer,
+ });
+ await highlighter.show(nodeFront, options);
+ this._afterShowHighlighterTypeForNode(type, nodeFront, options);
+
+ // Emit any type-specific highlighter shown event for tests
+ // which have not yet been updated to listen for the generic event
+ if (HIGHLIGHTER_EVENTS[type]?.shown) {
+ this.emit(HIGHLIGHTER_EVENTS[type].shown, nodeFront, options);
+ }
+ this.emit("highlighter-shown", { type, highlighter, nodeFront, options });
+ }
+
+ /**
+ * Set a timer to automatically hide all highlighters of a given type after a delay.
+ *
+ * @param {String} type
+ * Highlighter type to hide.
+ * @param {Number|undefined} duration
+ * Delay in milliseconds after which to hide the highlighter.
+ * If a duration is not provided, return early without scheduling a task.
+ * @return {Number|undefined}
+ * Index of the scheduled task returned by setTimeout().
+ */
+ scheduleAutoHideHighlighterType(type, duration) {
+ if (!duration) {
+ return undefined;
+ }
+
+ const timer = setTimeout(async () => {
+ await this.hideHighlighterType(type);
+ clearTimeout(timer);
+ }, duration);
+
+ return timer;
+ }
+
+ /**
+ * Hide all instances of a given highlighter type.
+ *
+ * @param {String} type
+ * Highlighter type to hide.
+ * @return {Promise}
+ */
+ async hideHighlighterType(type) {
+ if (this._pendingHighlighters.has(type)) {
+ // Abort pending highlighters for the given type.
+ this._pendingHighlighters.delete(type);
+ }
+ if (!this._activeHighlighters.has(type)) {
+ return;
+ }
+
+ const data = this.getDataForActiveHighlighter(type);
+ const { highlighter, nodeFront, timer } = data;
+ // Clear any autohide timer associated with this highlighter type.
+ clearTimeout(timer);
+ // Remove any metadata used to restore this highlighter type on page refresh.
+ this._restorableHighlighters.delete(type);
+ this._activeHighlighters.delete(type);
+ this._beforeHideHighlighterType(type);
+ await highlighter.hide();
+
+ // Emit any type-specific highlighter hidden event for tests
+ // which have not yet been updated to listen for the generic event
+ if (HIGHLIGHTER_EVENTS[type]?.hidden) {
+ this.emit(HIGHLIGHTER_EVENTS[type].hidden, nodeFront);
+ }
+ this.emit("highlighter-hidden", { type, ...data });
+ }
+
+ /**
+ * Returns true if the grid highlighter can be toggled on/off for the given node, and
+ * false otherwise. A grid container can be toggled on if the max grid highlighters
+ * is only 1 or less than the maximum grid highlighters that can be displayed or if
+ * the grid highlighter already highlights the given node.
+ *
+ * @param {NodeFront} node
+ * Grid container NodeFront.
+ * @return {Boolean}
+ */
+ canGridHighlighterToggle(node) {
+ return (
+ this.maxGridHighlighters === 1 ||
+ this.gridHighlighters.size < this.maxGridHighlighters ||
+ this.gridHighlighters.has(node)
+ );
+ }
+
+ /**
+ * Returns true when the maximum number of grid highlighter instances is reached.
+ * FIXME: Bug 1572652 should address this constraint.
+ *
+ * @return {Boolean}
+ */
+ isGridHighlighterLimitReached() {
+ return this.gridHighlighters.size === this.maxGridHighlighters;
+ }
+
+ /**
+ * Returns whether `node` is somewhere inside the DOM of the rule view.
+ *
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+ isRuleView(node) {
+ return !!node.closest("#ruleview-panel");
+ }
+
+ /**
+ * Add the highlighters overlay to the view. This will start tracking mouse events
+ * and display highlighters when needed.
+ *
+ * @param {CssRuleView|CssComputedView|LayoutView} view
+ * Either the rule-view or computed-view panel to add the highlighters overlay.
+ */
+ addToView(view) {
+ const el = view.element;
+ el.addEventListener("click", this.onClick, true);
+ el.addEventListener("mousemove", this.onMouseMove);
+ el.addEventListener("mouseout", this.onMouseOut);
+ el.ownerDocument.defaultView.addEventListener("mouseout", this.onMouseOut);
+ }
+
+ /**
+ * Remove the overlay from the given view. This will stop tracking mouse movement and
+ * showing highlighters.
+ *
+ * @param {CssRuleView|CssComputedView|LayoutView} view
+ * Either the rule-view or computed-view panel to remove the highlighters
+ * overlay.
+ */
+ removeFromView(view) {
+ const el = view.element;
+ el.removeEventListener("click", this.onClick, true);
+ el.removeEventListener("mousemove", this.onMouseMove);
+ el.removeEventListener("mouseout", this.onMouseOut);
+ }
+
+ /**
+ * Toggle the shapes highlighter for the given node.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element with a shape to highlight.
+ * @param {Object} options
+ * Object used for passing options to the shapes highlighter.
+ * @param {TextProperty} textProperty
+ * TextProperty where to write changes.
+ */
+ async toggleShapesHighlighter(node, options, textProperty) {
+ const shapesEditor = await this.getInContextEditor(node, "shapesEditor");
+ if (!shapesEditor) {
+ return;
+ }
+ shapesEditor.toggle(node, options, textProperty);
+ }
+
+ /**
+ * Show the shapes highlighter for the given node.
+ * This method delegates to the in-context shapes editor.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element with a shape to highlight.
+ * @param {Object} options
+ * Object used for passing options to the shapes highlighter.
+ */
+ async showShapesHighlighter(node, options) {
+ const shapesEditor = await this.getInContextEditor(node, "shapesEditor");
+ if (!shapesEditor) {
+ return;
+ }
+ shapesEditor.show(node, options);
+ }
+
+ /**
+ * Called after the shape highlighter was shown.
+ *
+ * @param {Object} data
+ * Data associated with the event.
+ * Contains:
+ * - {NodeFront} node: The NodeFront of the element that is highlighted.
+ * - {Object} options: Options that were passed to ShapesHighlighter.show()
+ */
+ onShapesHighlighterShown(data) {
+ const { node, options } = data;
+ this.shapesHighlighterShown = node;
+ this.state.shapes.options = options;
+ this.emit("shapes-highlighter-shown", node, options);
+ }
+
+ /**
+ * Hide the shapes highlighter if visible.
+ * This method delegates the to the in-context shapes editor which wraps
+ * the shapes highlighter with additional functionality.
+ *
+ * @param {NodeFront} node.
+ */
+ async hideShapesHighlighter(node) {
+ const shapesEditor = await this.getInContextEditor(node, "shapesEditor");
+ if (!shapesEditor) {
+ return;
+ }
+ shapesEditor.hide();
+ }
+
+ /**
+ * Called after the shapes highlighter was hidden.
+ *
+ * @param {Object} data
+ * Data associated with the event.
+ * Contains:
+ * - {NodeFront} node: The NodeFront of the element that was highlighted.
+ */
+ onShapesHighlighterHidden(data) {
+ this.emit(
+ "shapes-highlighter-hidden",
+ this.shapesHighlighterShown,
+ this.state.shapes.options
+ );
+ this.shapesHighlighterShown = null;
+ this.state.shapes = {};
+ }
+
+ /**
+ * Show the shapes highlighter for the given element, with the given point highlighted.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element to highlight.
+ * @param {String} point
+ * The point to highlight in the shapes highlighter.
+ */
+ async hoverPointShapesHighlighter(node, point) {
+ if (node == this.shapesHighlighterShown) {
+ const options = Object.assign({}, this.state.shapes.options);
+ options.hoverPoint = point;
+ await this.showShapesHighlighter(node, options);
+ }
+ }
+
+ /**
+ * Returns the flexbox highlighter color for the given node.
+ */
+ async getFlexboxHighlighterColor() {
+ // Load the Redux slice for flexbox if not yet available.
+ const state = this.store.getState();
+ if (!state.flexbox) {
+ this.store.injectReducer("flexbox", flexboxReducer);
+ }
+
+ // Attempt to get the flexbox highlighter color from the Redux store.
+ const { flexbox } = this.store.getState();
+ const color = flexbox.color;
+
+ if (color) {
+ return color;
+ }
+
+ // If the flexbox inspector has not been initialized, attempt to get the flexbox
+ // highlighter from the async storage.
+ const customHostColors =
+ (await asyncStorage.getItem("flexboxInspectorHostColors")) || {};
+
+ // Get the hostname, if there is no hostname, fall back on protocol
+ // ex: `data:` uri, and `about:` pages
+ let hostname;
+ try {
+ hostname =
+ parseURL(this.target.url).hostname ||
+ parseURL(this.target.url).protocol;
+ } catch (e) {
+ this._handleRejection(e);
+ }
+
+ return hostname && customHostColors[hostname]
+ ? customHostColors[hostname]
+ : DEFAULT_HIGHLIGHTER_COLOR;
+ }
+
+ /**
+ * Toggle the flexbox highlighter for the given flexbox container element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the flexbox container element to highlight.
+ * @param. {String} trigger
+ * String name matching "layout", "markup" or "rule" to indicate where the
+ * flexbox highlighter was toggled on from. "layout" represents the layout view.
+ * "markup" represents the markup view. "rule" represents the rule view.
+ */
+ async toggleFlexboxHighlighter(node, trigger) {
+ const highlightedNode = this.getNodeForActiveHighlighter(TYPES.FLEXBOX);
+ if (node == highlightedNode) {
+ await this.hideFlexboxHighlighter(node);
+ return;
+ }
+
+ await this.showFlexboxHighlighter(node, {}, trigger);
+ }
+
+ /**
+ * Show the flexbox highlighter for the given flexbox container element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the flexbox container element to highlight.
+ * @param {Object} options
+ * Object used for passing options to the flexbox highlighter.
+ * @param. {String} trigger
+ * String name matching "layout", "markup" or "rule" to indicate where the
+ * flexbox highlighter was toggled on from. "layout" represents the layout view.
+ * "markup" represents the markup view. "rule" represents the rule view.
+ * Will be passed as an option even though the highlighter doesn't use it
+ * in order to log telemetry in _afterShowHighlighterTypeForNode()
+ */
+ async showFlexboxHighlighter(node, options, trigger) {
+ const color = await this.getFlexboxHighlighterColor(node);
+ await this.showHighlighterTypeForNode(TYPES.FLEXBOX, node, {
+ ...options,
+ trigger,
+ color,
+ });
+ }
+
+ /**
+ * Hide the flexbox highlighter if any instance is visible.
+ */
+ async hideFlexboxHighlighter() {
+ await this.hideHighlighterType(TYPES.FLEXBOX);
+ }
+
+ /**
+ * Create a grid highlighter settings object for the provided nodeFront.
+ *
+ * @param {NodeFront} nodeFront
+ * The NodeFront for which we need highlighter settings.
+ */
+ getGridHighlighterSettings(nodeFront) {
+ // Load the Redux slices for grids and grid highlighter settings if not yet available.
+ const state = this.store.getState();
+ if (!state.grids) {
+ this.store.injectReducer("grids", gridsReducer);
+ }
+
+ if (!state.highlighterSettings) {
+ this.store.injectReducer(
+ "highlighterSettings",
+ highlighterSettingsReducer
+ );
+ }
+
+ // Get grids and grid highlighter settings from the latest Redux state
+ // in case they were just added above.
+ const { grids, highlighterSettings } = this.store.getState();
+ const grid = grids.find(g => g.nodeFront === nodeFront);
+ const color = grid ? grid.color : DEFAULT_HIGHLIGHTER_COLOR;
+ const zIndex = grid ? grid.zIndex : 0;
+ return Object.assign({}, highlighterSettings, { color, zIndex });
+ }
+
+ /**
+ * Return a list of all node fronts that are highlighted with a Grid highlighter.
+ *
+ * @return {Array}
+ */
+ getHighlightedGridNodes() {
+ return [...Array.from(this.gridHighlighters.keys())];
+ }
+
+ /**
+ * Toggle the grid highlighter for the given grid container element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the grid container element to highlight.
+ * @param. {String} trigger
+ * String name matching "grid", "markup" or "rule" to indicate where the
+ * grid highlighter was toggled on from. "grid" represents the grid view.
+ * "markup" represents the markup view. "rule" represents the rule view.
+ */
+ async toggleGridHighlighter(node, trigger) {
+ if (this.gridHighlighters.has(node)) {
+ await this.hideGridHighlighter(node);
+ return;
+ }
+
+ await this.showGridHighlighter(node, {}, trigger);
+ }
+
+ /**
+ * Show the grid highlighter for the given grid container element.
+ * Allow as many active highlighter instances as permitted by the
+ * maxGridHighlighters limit (default 3).
+ *
+ * Logic of showing grid highlighters:
+ * - GRID:
+ * - Show a highlighter for a grid container when explicitly requested
+ * (ex. click badge in Markup view) and count it against the limit.
+ * - When the limit of active highlighters is reached, do no show any more
+ * until other instances are hidden. If configured to show only one instance,
+ * hide the existing highlighter before showing a new one.
+ *
+ * - SUBGRID:
+ * - When a highlighter for a subgrid is shown, also show a highlighter for its parent
+ * grid, but with faded-out colors (serves as a visual reference for the subgrid)
+ * - The "active" state of the highlighter for the parent grid is not reflected
+ * in the UI (checkboxes in the Layout panel, badges in the Markup view, etc.)
+ * - The highlighter for the parent grid DOES NOT count against the highlighter limit
+ * - If the highlighter for the parent grid is explicitly requested to be shown
+ * (ex: click badge in Markup view), show it in full color and reflect its "active"
+ * state in the UI (checkboxes in the Layout panel, badges in the Markup view)
+ * - When a highlighter for a subgrid is hidden, also hide the highlighter for its
+ * parent grid; if the parent grid was explicitly requested separately, keep the
+ * highlighter for the parent grid visible, but show it in full color.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the grid container element to highlight.
+ * @param {Object} options
+ * Object used for passing options to the grid highlighter.
+ * @param {String} trigger
+ * String name matching "grid", "markup" or "rule" to indicate where the
+ * grid highlighter was toggled on from. "grid" represents the grid view.
+ * "markup" represents the markup view. "rule" represents the rule view.
+ */
+ async showGridHighlighter(node, options, trigger) {
+ if (!this.gridHighlighters.has(node)) {
+ // If only one grid highlighter can be shown at a time, hide the other instance.
+ // Otherwise, if the max highlighter limit is reached, do not show another one.
+ if (this.maxGridHighlighters === 1) {
+ await this.hideGridHighlighter(
+ this.gridHighlighters.keys().next().value
+ );
+ } else if (this.gridHighlighters.size === this.maxGridHighlighters) {
+ return;
+ }
+ }
+
+ // If the given node is already highlighted as the parent grid for a subgrid,
+ // hide the parent grid highlighter because it will be explicitly shown below.
+ const isHighlightedAsParentGrid = Array.from(this.gridHighlighters.values())
+ .map(value => value.parentGridNode)
+ .includes(node);
+ if (isHighlightedAsParentGrid) {
+ await this.hideParentGridHighlighter(node);
+ }
+
+ // Show a translucent highlight of the parent grid container if the given node is
+ // a subgrid and the parent grid container is not already explicitly highlighted.
+ let parentGridNode = null;
+ let parentGridHighlighter = null;
+ if (node.displayType === "subgrid") {
+ parentGridNode = await node.walkerFront.getParentGridNode(node);
+ parentGridHighlighter = await this.showParentGridHighlighter(
+ parentGridNode
+ );
+ }
+
+ // When changing highlighter colors, we call highlighter.show() again with new options
+ // Reuse the active highlighter instance if present; avoid creating new highlighters
+ let highlighter;
+ if (this.gridHighlighters.has(node)) {
+ highlighter = this.gridHighlighters.get(node).highlighter;
+ }
+
+ if (!highlighter) {
+ highlighter = await this._getHighlighterTypeForNode(TYPES.GRID, node);
+ }
+
+ this.gridHighlighters.set(node, {
+ highlighter,
+ parentGridNode,
+ parentGridHighlighter,
+ });
+
+ options = { ...options, ...this.getGridHighlighterSettings(node) };
+ await highlighter.show(node, options);
+
+ this._afterShowHighlighterTypeForNode(TYPES.GRID, node, {
+ ...options,
+ trigger,
+ });
+
+ try {
+ // Save grid highlighter state.
+ const { url } = this.target;
+
+ const selectors =
+ await this.inspector.commands.inspectorCommand.getNodeFrontSelectorsFromTopDocument(
+ node
+ );
+
+ this.state.grids.set(node, { selectors, options, url });
+
+ // Emit the NodeFront of the grid container element that the grid highlighter was
+ // shown for, and its options for testing the highlighter setting options.
+ this.emit("grid-highlighter-shown", node, options);
+
+ // XXX: Shim to use generic highlighter events until addressing Bug 1572652
+ // Ensures badges in the Markup view reflect the state of the grid highlighter.
+ this.emit("highlighter-shown", {
+ type: TYPES.GRID,
+ nodeFront: node,
+ highlighter,
+ options,
+ });
+ } catch (e) {
+ this._handleRejection(e);
+ }
+ }
+
+ /**
+ * Show the grid highlighter for the given subgrid's parent grid container element.
+ * The parent grid highlighter is shown with faded-out colors, as opposed
+ * to the full-color grid highlighter shown when calling showGridHighlighter().
+ * If the grid container is already explicitly highlighted (i.e. standalone grid),
+ * skip showing the another grid highlighter for it.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the parent grid container element to highlight.
+ * @returns {Promise}
+ * Resolves with either the highlighter instance or null if it was skipped.
+ */
+ async showParentGridHighlighter(node) {
+ const isHighlighted = Array.from(this.gridHighlighters.keys()).includes(
+ node
+ );
+
+ if (!node || isHighlighted) {
+ return null;
+ }
+
+ // Get the parent grid highlighter for the parent grid container if one already exists
+ let highlighter = this.getParentGridHighlighter(node);
+ if (!highlighter) {
+ highlighter = await this._getHighlighterTypeForNode(TYPES.GRID, node);
+ }
+
+ await highlighter.show(node, {
+ ...this.getGridHighlighterSettings(node),
+ // Configure the highlighter with faded-out colors.
+ globalAlpha: SUBGRID_PARENT_ALPHA,
+ });
+
+ return highlighter;
+ }
+
+ /**
+ * Get the parent grid highlighter associated with the given node
+ * if the node is a parent grid container for a highlighted subgrid.
+ *
+ * @param {NodeFront} node
+ * NodeFront of the parent grid container for a subgrid.
+ * @return {CustomHighlighterFront|null}
+ */
+ getParentGridHighlighter(node) {
+ // Find the highlighter map value for the subgrid whose parent grid is the given node.
+ const value = Array.from(this.gridHighlighters.values()).find(
+ ({ parentGridNode }) => {
+ return parentGridNode === node;
+ }
+ );
+
+ if (!value) {
+ return null;
+ }
+
+ const { parentGridHighlighter } = value;
+ return parentGridHighlighter;
+ }
+
+ /**
+ * Restore the parent grid highlighter for a subgrid.
+ *
+ * A grid node can be highlighted both explicitly (ex: by clicking a badge in the
+ * Markup view) and implicitly, as a parent grid for a subgrid.
+ *
+ * An explicit grid highlighter overwrites a subgrid's parent grid highlighter.
+ * After an explicit grid highlighter for a node is hidden, but that node is also the
+ * parent grid container for a subgrid which is still highlighted, restore the implicit
+ * parent grid highlighter.
+ *
+ * @param {NodeFront} node
+ * NodeFront for a grid node which may also be a subgrid's parent grid
+ * container.
+ * @return {Promise}
+ */
+ async restoreParentGridHighlighter(node) {
+ // Find the highlighter map entry for the subgrid whose parent grid is the given node.
+ const entry = Array.from(this.gridHighlighters.entries()).find(
+ ([key, value]) => {
+ return value?.parentGridNode === node;
+ }
+ );
+
+ if (!Array.isArray(entry)) {
+ return;
+ }
+
+ const [highlightedSubgridNode, data] = entry;
+ if (!data.parentGridHighlighter) {
+ const parentGridHighlighter = await this.showParentGridHighlighter(node);
+ this.gridHighlighters.set(highlightedSubgridNode, {
+ ...data,
+ parentGridHighlighter,
+ });
+ }
+ }
+
+ /**
+ * Hide the grid highlighter for the given grid container element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the grid container element to unhighlight.
+ */
+ async hideGridHighlighter(node) {
+ const { highlighter, parentGridNode } =
+ this.gridHighlighters.get(node) || {};
+
+ if (!highlighter) {
+ return;
+ }
+
+ // Hide the subgrid's parent grid highlighter, if any.
+ if (parentGridNode) {
+ await this.hideParentGridHighlighter(parentGridNode);
+ }
+
+ this._beforeHideHighlighterType(TYPES.GRID);
+ // Don't just hide the highlighter, destroy the front instance to release memory.
+ // If another highlighter is shown later, a new front will be created.
+ highlighter.destroy();
+ this.gridHighlighters.delete(node);
+ this.state.grids.delete(node);
+
+ // It's possible we just destroyed the grid highlighter for a node which also serves
+ // as a subgrid's parent grid. If so, restore the parent grid highlighter.
+ await this.restoreParentGridHighlighter(node);
+
+ // Emit the NodeFront of the grid container element that the grid highlighter was
+ // hidden for.
+ this.emit("grid-highlighter-hidden", node);
+
+ // XXX: Shim to use generic highlighter events until addressing Bug 1572652
+ // Ensures badges in the Markup view reflect the state of the grid highlighter.
+ this.emit("highlighter-hidden", {
+ type: TYPES.GRID,
+ nodeFront: node,
+ });
+ }
+
+ /**
+ * Hide the parent grid highlighter for the given parent grid container element.
+ * If there are multiple subgrids with the same parent grid, do not hide the parent
+ * grid highlighter.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the parent grid container element to unhiglight.
+ */
+ async hideParentGridHighlighter(node) {
+ let count = 0;
+ let parentGridHighlighter;
+ let subgridNode;
+ for (const [key, value] of this.gridHighlighters.entries()) {
+ if (value.parentGridNode === node) {
+ parentGridHighlighter = value.parentGridHighlighter;
+ subgridNode = key;
+ count++;
+ }
+ }
+
+ if (!parentGridHighlighter || count > 1) {
+ return;
+ }
+
+ // Destroy the highlighter front instance to release memory.
+ parentGridHighlighter.destroy();
+
+ // Update the grid highlighter entry to indicate the parent grid highlighter is gone.
+ this.gridHighlighters.set(subgridNode, {
+ ...this.gridHighlighters.get(subgridNode),
+ parentGridHighlighter: null,
+ });
+ }
+
+ /**
+ * Toggle the geometry editor highlighter for the given element.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the element to highlight.
+ */
+ async toggleGeometryHighlighter(node) {
+ if (node == this.geometryEditorHighlighterShown) {
+ await this.hideGeometryEditor();
+ return;
+ }
+
+ await this.showGeometryEditor(node);
+ }
+
+ /**
+ * Show the geometry editor highlightor for the given element.
+ *
+ * @param {NodeFront} node
+ * THe NodeFront of the element to highlight.
+ */
+ async showGeometryEditor(node) {
+ const highlighter = await this._getHighlighterTypeForNode(
+ "GeometryEditorHighlighter",
+ node
+ );
+ if (!highlighter) {
+ return;
+ }
+
+ const isShown = await highlighter.show(node);
+ if (!isShown) {
+ return;
+ }
+
+ this.emit("geometry-editor-highlighter-shown");
+ this.geometryEditorHighlighterShown = node;
+ }
+
+ /**
+ * Hide the geometry editor highlighter.
+ */
+ async hideGeometryEditor() {
+ if (!this.geometryEditorHighlighterShown) {
+ return;
+ }
+
+ const highlighter =
+ this.geometryEditorHighlighterShown.inspectorFront.getKnownHighlighter(
+ "GeometryEditorHighlighter"
+ );
+
+ if (!highlighter) {
+ return;
+ }
+
+ await highlighter.hide();
+
+ this.emit("geometry-editor-highlighter-hidden");
+ this.geometryEditorHighlighterShown = null;
+ }
+
+ /**
+ * Restores the saved flexbox highlighter state.
+ */
+ async restoreFlexboxState() {
+ const state = this._restorableHighlighters.get(TYPES.FLEXBOX);
+ if (!state) {
+ return;
+ }
+
+ this._restorableHighlighters.delete(TYPES.FLEXBOX);
+ await this.restoreState(TYPES.FLEXBOX, state, this.showFlexboxHighlighter);
+ }
+
+ /**
+ * Restores the saved grid highlighter state.
+ */
+ async restoreGridState() {
+ // The NodeFronts that are used as the keys in the grid state Map are no longer in the
+ // tree after a reload. To clean up the grid state, we create a copy of the values of
+ // the grid state before restoring and clear it.
+ const values = [...this.state.grids.values()];
+ this.state.grids.clear();
+
+ try {
+ for (const gridState of values) {
+ await this.restoreState(
+ TYPES.GRID,
+ gridState,
+ this.showGridHighlighter
+ );
+ }
+ } catch (e) {
+ this._handleRejection(e);
+ }
+ }
+
+ /**
+ * Helper function called by restoreFlexboxState, restoreGridState.
+ * Restores the saved highlighter state for the given highlighter
+ * and their state.
+ *
+ * @param {String} type
+ * Highlighter type to be restored.
+ * @param {Object} state
+ * Object containing the metadata used to restore the highlighter.
+ * {Array} state.selectors
+ * Array of CSS selector which identifies the node to be highlighted.
+ * If the node is in the top-level document, the array contains just one item.
+ * Otherwise, if the node is nested within a stack of iframes, each iframe is
+ * identified by its unique selector; the last item in the array identifies
+ * the target node within its host iframe document.
+ * {Object} state.options
+ * Configuration options to use when showing the highlighter.
+ * {String} state.url
+ * URL of the top-level target when the metadata was stored. Used to identify
+ * if there was a page refresh or a navigation away to a different page.
+ * @param {Function} showFunction
+ * The function that shows the highlighter
+ * @return {Promise} that resolves when the highlighter was restored and shown.
+ */
+ async restoreState(type, state, showFunction) {
+ const { selectors = [], options, url } = state;
+
+ if (!selectors.length || url !== this.target.url) {
+ // Bail out if no selector was saved, or if we are on a different page.
+ this.emit(`highlighter-discarded`, { type });
+ return;
+ }
+
+ const nodeFront =
+ await this.inspector.commands.inspectorCommand.findNodeFrontFromSelectors(
+ selectors
+ );
+
+ if (nodeFront) {
+ await showFunction(nodeFront, options);
+ this.emit(`highlighter-restored`, { type });
+ } else {
+ this.emit(`highlighter-discarded`, { type });
+ }
+ }
+
+ /**
+ * Get an instance of an in-context editor for the given type.
+ *
+ * In-context editors behave like highlighters but with added editing capabilities which
+ * need to write value changes back to something, like to properties in the Rule view.
+ * They typically exist in the context of the page, like the ShapesInContextEditor.
+ *
+ * @param {NodeFront} node.
+ * @param {String} type
+ * Type of in-context editor. Currently supported: "shapesEditor"
+ * @return {Object|null}
+ * Reference to instance for given type of in-context editor or null.
+ */
+ async getInContextEditor(node, type) {
+ if (this.editors[type]) {
+ return this.editors[type];
+ }
+
+ let editor;
+
+ switch (type) {
+ case "shapesEditor":
+ const highlighter = await this._getHighlighterTypeForNode(
+ "ShapesHighlighter",
+ node
+ );
+ if (!highlighter) {
+ return null;
+ }
+ const ShapesInContextEditor = require("resource://devtools/client/shared/widgets/ShapesInContextEditor.js");
+
+ editor = new ShapesInContextEditor(
+ highlighter,
+ this.inspector,
+ this.state
+ );
+ editor.on("show", this.onShapesHighlighterShown);
+ editor.on("hide", this.onShapesHighlighterHidden);
+ break;
+ default:
+ throw new Error(`Unsupported in-context editor '${name}'`);
+ }
+
+ this.editors[type] = editor;
+
+ return editor;
+ }
+
+ /**
+ * Get a highlighter front given a type. It will only be initialized once.
+ *
+ * @param {String} type
+ * The highlighter type. One of this.highlighters.
+ * @return {Promise} that resolves to the highlighter
+ */
+ async _getHighlighter(type) {
+ if (this.highlighters[type]) {
+ return this.highlighters[type];
+ }
+
+ let highlighter;
+
+ try {
+ highlighter = await this.inspectorFront.getHighlighterByType(type);
+ } catch (e) {
+ this._handleRejection(e);
+ }
+
+ if (!highlighter) {
+ return null;
+ }
+
+ this.highlighters[type] = highlighter;
+ return highlighter;
+ }
+
+ /**
+ * Ignore unexpected errors from async function calls
+ * if HighlightersOverlay has been destroyed.
+ *
+ * @param {Error} error
+ */
+ _handleRejection(error) {
+ if (!this.destroyed) {
+ console.error(error);
+ }
+ }
+
+ /**
+ * Toggle the class "active" on the given shape point in the rule view if the current
+ * inspector selection is highlighted by the shapes highlighter.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of the shape point to toggle
+ * @param {Boolean} active
+ * Whether the shape point should be active
+ */
+ _toggleShapePointActive(node, active) {
+ if (this.inspector.selection.nodeFront != this.shapesHighlighterShown) {
+ return;
+ }
+
+ node.classList.toggle("active", active);
+ }
+
+ /**
+ * Hide the currently shown hovered highlighter.
+ */
+ _hideHoveredHighlighter() {
+ if (
+ !this.hoveredHighlighterShown ||
+ !this.highlighters[this.hoveredHighlighterShown]
+ ) {
+ return;
+ }
+
+ // For some reason, the call to highlighter.hide doesn't always return a
+ // promise. This causes some tests to fail when trying to install a
+ // rejection handler on the result of the call. To avoid this, check
+ // whether the result is truthy before installing the handler.
+ const onHidden = this.highlighters[this.hoveredHighlighterShown].hide();
+ if (onHidden) {
+ onHidden.catch(console.error);
+ }
+
+ this.hoveredHighlighterShown = null;
+ this.emit("css-transform-highlighter-hidden");
+ }
+
+ /**
+ * Given a node front and a function that hides the given node's highlighter, hides
+ * the highlighter if the node front is no longer in the DOM tree. This is called
+ * from the "markupmutation" event handler.
+ *
+ * @param {NodeFront} node
+ * The NodeFront of a highlighted DOM node.
+ * @param {Function} hideHighlighter
+ * The function that will hide the highlighter of the highlighted node.
+ */
+ async _hideHighlighterIfDeadNode(node, hideHighlighter) {
+ if (!node) {
+ return;
+ }
+
+ try {
+ const isInTree =
+ node.walkerFront && (await node.walkerFront.isInDOMTree(node));
+ if (!isInTree) {
+ await hideHighlighter(node);
+ }
+ } catch (e) {
+ this._handleRejection(e);
+ }
+ }
+
+ /**
+ * Is the current hovered node a css transform property value in the
+ * computed-view.
+ *
+ * @param {Object} nodeInfo
+ * @return {Boolean}
+ */
+ _isComputedViewTransform(nodeInfo) {
+ if (nodeInfo.view != "computed") {
+ return false;
+ }
+ return (
+ nodeInfo.type === VIEW_NODE_VALUE_TYPE &&
+ nodeInfo.value.property === "transform"
+ );
+ }
+
+ /**
+ * Does the current clicked node have the shapes highlighter toggle in the
+ * rule-view.
+ *
+ * @param {DOMNode} node
+ * @return {Boolean}
+ */
+ _isRuleViewShapeSwatch(node) {
+ return (
+ this.isRuleView(node) && node.classList.contains("ruleview-shapeswatch")
+ );
+ }
+
+ /**
+ * Is the current hovered node a css transform property value in the rule-view.
+ *
+ * @param {Object} nodeInfo
+ * @return {Boolean}
+ */
+ _isRuleViewTransform(nodeInfo) {
+ if (nodeInfo.view != "rule") {
+ return false;
+ }
+ const isTransform =
+ nodeInfo.type === VIEW_NODE_VALUE_TYPE &&
+ nodeInfo.value.property === "transform";
+ const isEnabled =
+ nodeInfo.value.enabled &&
+ !nodeInfo.value.overridden &&
+ !nodeInfo.value.pseudoElement;
+ return isTransform && isEnabled;
+ }
+
+ /**
+ * Is the current hovered node a highlightable shape point in the rule-view.
+ *
+ * @param {Object} nodeInfo
+ * @return {Boolean}
+ */
+ isRuleViewShapePoint(nodeInfo) {
+ if (nodeInfo.view != "rule") {
+ return false;
+ }
+ const isShape =
+ nodeInfo.type === VIEW_NODE_SHAPE_POINT_TYPE &&
+ (nodeInfo.value.property === "clip-path" ||
+ nodeInfo.value.property === "shape-outside");
+ const isEnabled =
+ nodeInfo.value.enabled &&
+ !nodeInfo.value.overridden &&
+ !nodeInfo.value.pseudoElement;
+ return (
+ isShape &&
+ isEnabled &&
+ nodeInfo.value.toggleActive &&
+ !this.state.shapes.options.transformMode
+ );
+ }
+
+ onClick(event) {
+ if (this._isRuleViewShapeSwatch(event.target)) {
+ event.stopPropagation();
+
+ const view = this.inspector.getPanel("ruleview").view;
+ const nodeInfo = view.getNodeInfo(event.target);
+
+ this.toggleShapesHighlighter(
+ this.inspector.selection.nodeFront,
+ {
+ mode: event.target.dataset.mode,
+ transformMode: event.metaKey || event.ctrlKey,
+ },
+ nodeInfo.value.textProperty
+ );
+ }
+ }
+
+ /**
+ * Handler for "display-change" events from walker fronts. Hides the flexbox or
+ * grid highlighter if their respective node is no longer a flex container or
+ * grid container.
+ *
+ * @param {Array} nodes
+ * An array of nodeFronts
+ */
+ async onDisplayChange(nodes) {
+ const highlightedGridNodes = this.getHighlightedGridNodes();
+
+ for (const node of nodes) {
+ const display = node.displayType;
+
+ // Hide the flexbox highlighter if the node is no longer a flexbox container.
+ if (
+ display !== "flex" &&
+ display !== "inline-flex" &&
+ node == this.getNodeForActiveHighlighter(TYPES.FLEXBOX)
+ ) {
+ await this.hideFlexboxHighlighter(node);
+ return;
+ }
+
+ // Hide the grid highlighter if the node is no longer a grid container.
+ if (
+ display !== "grid" &&
+ display !== "inline-grid" &&
+ display !== "subgrid" &&
+ highlightedGridNodes.includes(node)
+ ) {
+ await this.hideGridHighlighter(node);
+ return;
+ }
+ }
+ }
+
+ onMouseMove(event) {
+ // Bail out if the target is the same as for the last mousemove.
+ if (event.target === this._lastHovered) {
+ return;
+ }
+
+ // Only one highlighter can be displayed at a time, hide the currently shown.
+ this._hideHoveredHighlighter();
+
+ this._lastHovered = event.target;
+
+ const view = this.isRuleView(this._lastHovered)
+ ? this.inspector.getPanel("ruleview").view
+ : this.inspector.getPanel("computedview").computedView;
+ const nodeInfo = view.getNodeInfo(event.target);
+ if (!nodeInfo) {
+ return;
+ }
+
+ if (this.isRuleViewShapePoint(nodeInfo)) {
+ const { point } = nodeInfo.value;
+ this.hoverPointShapesHighlighter(
+ this.inspector.selection.nodeFront,
+ point
+ );
+ return;
+ }
+
+ // Choose the type of highlighter required for the hovered node.
+ let type;
+ if (
+ this._isRuleViewTransform(nodeInfo) ||
+ this._isComputedViewTransform(nodeInfo)
+ ) {
+ type = "CssTransformHighlighter";
+ }
+
+ if (type) {
+ this.hoveredHighlighterShown = type;
+ const node = this.inspector.selection.nodeFront;
+ this._getHighlighter(type)
+ .then(highlighter => highlighter.show(node))
+ .then(shown => {
+ if (shown) {
+ this.emit("css-transform-highlighter-shown");
+ }
+ });
+ }
+ }
+
+ onMouseOut(event) {
+ // Only hide the highlighter if the mouse leaves the currently hovered node.
+ if (
+ !this._lastHovered ||
+ (event && this._lastHovered.contains(event.relatedTarget))
+ ) {
+ return;
+ }
+
+ // Otherwise, hide the highlighter.
+ const view = this.isRuleView(this._lastHovered)
+ ? this.inspector.getPanel("ruleview").view
+ : this.inspector.getPanel("computedview").computedView;
+ const nodeInfo = view.getNodeInfo(this._lastHovered);
+ if (nodeInfo && this.isRuleViewShapePoint(nodeInfo)) {
+ this.hoverPointShapesHighlighter(
+ this.inspector.selection.nodeFront,
+ null
+ );
+ }
+ this._lastHovered = null;
+ this._hideHoveredHighlighter();
+ }
+
+ /**
+ * Handler function called when a new root-node has been added in the
+ * inspector. Nodes may have been added / removed and highlighters should
+ * be updated.
+ */
+ async _onResourceAvailable(resources) {
+ for (const resource of resources) {
+ if (
+ resource.resourceType !== this.resourceCommand.TYPES.ROOT_NODE ||
+ // It might happen that the ROOT_NODE resource (which is a Front) is already
+ // destroyed, and in such case we want to ignore it.
+ resource.isDestroyed()
+ ) {
+ // Only handle root-node resources.
+ // Note that we could replace this with DOCUMENT_EVENT resources, since
+ // the actual root-node resource is not used here.
+ continue;
+ }
+
+ if (resource.targetFront.isTopLevel && resource.isTopLevelDocument) {
+ // The topmost root node will lead to the destruction and recreation of
+ // the MarkupView, and highlighters will be refreshed afterwards. This is
+ // handled by the inspector.
+ continue;
+ }
+
+ await this._hideOrphanedHighlighters();
+ }
+ }
+
+ /**
+ * Handler function for "markupmutation" events. Hides the flexbox/grid/shapes
+ * highlighter if the flexbox/grid/shapes container is no longer in the DOM tree.
+ */
+ async onMarkupMutation(mutations) {
+ const hasInterestingMutation = mutations.some(
+ mut => mut.type === "childList"
+ );
+ if (!hasInterestingMutation) {
+ // Bail out if the mutations did not remove nodes, or if no grid highlighter is
+ // displayed.
+ return;
+ }
+
+ await this._hideOrphanedHighlighters();
+ }
+
+ /**
+ * Hide every active highlighter whose nodeFront is no longer present in the DOM.
+ * Returns a promise that resolves when all orphaned highlighters are hidden.
+ *
+ * @return {Promise}
+ */
+ async _hideOrphanedHighlighters() {
+ await this._hideHighlighterIfDeadNode(
+ this.shapesHighlighterShown,
+ this.hideShapesHighlighter
+ );
+
+ // Hide all active highlighters whose nodeFront is no longer attached.
+ const promises = [];
+ for (const [type, data] of this._activeHighlighters) {
+ promises.push(
+ this._hideHighlighterIfDeadNode(data.nodeFront, () => {
+ return this.hideHighlighterType(type);
+ })
+ );
+ }
+
+ const highlightedGridNodes = this.getHighlightedGridNodes();
+ for (const node of highlightedGridNodes) {
+ promises.push(
+ this._hideHighlighterIfDeadNode(node, this.hideGridHighlighter)
+ );
+ }
+
+ return Promise.all(promises);
+ }
+
+ /**
+ * Hides any visible highlighter and clear internal state. This should be called to
+ * have a clean slate, for example when the page navigates or when a given frame is
+ * selected in the iframe picker.
+ */
+ async hideAllHighlighters() {
+ this.destroyEditors();
+
+ // Hide any visible highlighters and clear any timers set to autohide highlighters.
+ for (const { highlighter, timer } of this._activeHighlighters.values()) {
+ await highlighter.hide();
+ clearTimeout(timer);
+ }
+
+ this._activeHighlighters.clear();
+ this._pendingHighlighters.clear();
+ this.gridHighlighters.clear();
+
+ this.geometryEditorHighlighterShown = null;
+ this.hoveredHighlighterShown = null;
+ this.shapesHighlighterShown = null;
+ }
+
+ /**
+ * Display a message about the simple highlighters which can be enabled for
+ * users relying on prefers-reduced-motion. This message will be a toolbox
+ * notification, which will contain a button to open the settings panel and
+ * will no longer be displayed if the user decides to explicitly close the
+ * message.
+ */
+ _showSimpleHighlightersMessage() {
+ const pref = "devtools.inspector.simple-highlighters.message-dismissed";
+ const messageDismissed = Services.prefs.getBoolPref(pref, false);
+ if (messageDismissed) {
+ return;
+ }
+ const notificationBox = this.inspector.toolbox.getNotificationBox();
+ const message = HighlightersBundle.formatValueSync(
+ "simple-highlighters-message"
+ );
+
+ notificationBox.appendNotification(
+ message,
+ "simple-highlighters-message",
+ null,
+ notificationBox.PRIORITY_INFO_MEDIUM,
+ [
+ {
+ label: HighlightersBundle.formatValueSync(
+ "simple-highlighters-settings-button"
+ ),
+ callback: async () => {
+ const { panelDoc } = await this.toolbox.selectTool("options");
+ const option = panelDoc.querySelector(
+ "[data-pref='devtools.inspector.simple-highlighters-reduced-motion']"
+ ).parentNode;
+ option.scrollIntoView({ block: "center" });
+ option.classList.add("options-panel-highlight");
+
+ // Emit a test-only event to know when the settings panel is opened.
+ this.toolbox.emitForTests("test-highlighters-settings-opened");
+ },
+ },
+ ],
+ evt => {
+ if (evt === "removed") {
+ // Flip the preference when the message is dismissed.
+ Services.prefs.setBoolPref(pref, true);
+ }
+ }
+ );
+ }
+
+ /**
+ * Destroy and clean-up all instances of in-context editors.
+ */
+ destroyEditors() {
+ for (const type in this.editors) {
+ this.editors[type].off("show");
+ this.editors[type].off("hide");
+ this.editors[type].destroy();
+ }
+
+ this.editors = {};
+ }
+
+ /**
+ * Destroy and clean-up all instances of highlighters.
+ */
+ destroyHighlighters() {
+ // Destroy all highlighters and clear any timers set to autohide highlighters.
+ const values = [
+ ...this._activeHighlighters.values(),
+ ...this.gridHighlighters.values(),
+ ];
+ for (const { highlighter, parentGridHighlighter, timer } of values) {
+ if (highlighter) {
+ highlighter.destroy();
+ }
+
+ if (parentGridHighlighter) {
+ parentGridHighlighter.destroy();
+ }
+
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+
+ this._activeHighlighters.clear();
+ this._pendingHighlighters.clear();
+ this.gridHighlighters.clear();
+
+ for (const type in this.highlighters) {
+ if (this.highlighters[type]) {
+ this.highlighters[type].finalize();
+ this.highlighters[type] = null;
+ }
+ }
+ }
+
+ /**
+ * Destroy this overlay instance, removing it from the view and destroying
+ * all initialized highlighters.
+ */
+ destroy() {
+ this.inspector.off("markupmutation", this.onMarkupMutation);
+ this.resourceCommand.unwatchResources(
+ [this.resourceCommand.TYPES.ROOT_NODE],
+ { onAvailable: this._onResourceAvailable }
+ );
+
+ this.walkerEventListener.destroy();
+ this.walkerEventListener = null;
+
+ this.destroyEditors();
+ this.destroyHighlighters();
+
+ this._lastHovered = null;
+
+ this.inspector = null;
+ this.state = null;
+ this.store = null;
+ this.telemetry = null;
+
+ this.geometryEditorHighlighterShown = null;
+ this.hoveredHighlighterShown = null;
+ this.shapesHighlighterShown = null;
+
+ this.destroyed = true;
+ }
+}
+
+HighlightersOverlay.TYPES = HighlightersOverlay.prototype.TYPES = TYPES;
+
+module.exports = HighlightersOverlay;