/* 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;