summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/shared
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/shared')
-rw-r--r--devtools/client/inspector/shared/highlighters-overlay.js2014
-rw-r--r--devtools/client/inspector/shared/moz.build18
-rw-r--r--devtools/client/inspector/shared/node-reps.js47
-rw-r--r--devtools/client/inspector/shared/node-types.js22
-rw-r--r--devtools/client/inspector/shared/style-change-tracker.js100
-rw-r--r--devtools/client/inspector/shared/style-inspector-menu.js502
-rw-r--r--devtools/client/inspector/shared/test/browser.toml44
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_01.js85
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_02.js115
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-urls.js160
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js381
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_active.js50
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_style_changes.js99
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_tooltip-background-image.js150
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_tooltip-closes-on-new-selection.js80
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_tooltip-longhand-fontfamily.js178
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_tooltip-multiple-background-images.js68
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_tooltip-shorthand-fontfamily.js73
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_tooltip-size.js90
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-01.js53
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-02.js63
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-03.js115
-rw-r--r--devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-04.js63
-rw-r--r--devtools/client/inspector/shared/test/doc_content_style_changes.html28
-rw-r--r--devtools/client/inspector/shared/test/head.js218
-rw-r--r--devtools/client/inspector/shared/tooltips-overlay.js570
-rw-r--r--devtools/client/inspector/shared/utils.js239
-rw-r--r--devtools/client/inspector/shared/walker-event-listener.js86
28 files changed, 5711 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..6082b8b842
--- /dev/null
+++ b/devtools/client/inspector/shared/highlighters-overlay.js
@@ -0,0 +1,2014 @@
+/* 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);
+ }
+ const options = {
+ ...this.getGridHighlighterSettings(node),
+ // Configure the highlighter with faded-out colors.
+ globalAlpha: SUBGRID_PARENT_ALPHA,
+ };
+ await highlighter.show(node, options);
+
+ this.emitForTests("highlighter-shown", {
+ type: TYPES.GRID,
+ nodeFront: node,
+ highlighter,
+ options,
+ });
+
+ 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;
diff --git a/devtools/client/inspector/shared/moz.build b/devtools/client/inspector/shared/moz.build
new file mode 100644
index 0000000000..6d7ce0590b
--- /dev/null
+++ b/devtools/client/inspector/shared/moz.build
@@ -0,0 +1,18 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ "highlighters-overlay.js",
+ "node-reps.js",
+ "node-types.js",
+ "style-change-tracker.js",
+ "style-inspector-menu.js",
+ "tooltips-overlay.js",
+ "utils.js",
+ "walker-event-listener.js",
+)
+
+BROWSER_CHROME_MANIFESTS += ["test/browser.toml"]
diff --git a/devtools/client/inspector/shared/node-reps.js b/devtools/client/inspector/shared/node-reps.js
new file mode 100644
index 0000000000..c93fc68f0e
--- /dev/null
+++ b/devtools/client/inspector/shared/node-reps.js
@@ -0,0 +1,47 @@
+/* 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";
+
+loader.lazyGetter(this, "MODE", function () {
+ return require("resource://devtools/client/shared/components/reps/index.js")
+ .MODE;
+});
+
+loader.lazyGetter(this, "ElementNode", function () {
+ return require("resource://devtools/client/shared/components/reps/reps/element-node.js");
+});
+
+loader.lazyGetter(this, "TextNode", function () {
+ return require("resource://devtools/client/shared/components/reps/reps/text-node.js");
+});
+
+loader.lazyRequireGetter(
+ this,
+ "translateNodeFrontToGrip",
+ "resource://devtools/client/inspector/shared/utils.js",
+ true
+);
+
+/**
+ * Creates either an ElementNode or a TextNode rep given a nodeFront. By default the
+ * rep is created in TINY mode.
+ *
+ * @param {NodeFront} nodeFront
+ * The node front to create the element for.
+ * @param {Object} props
+ * Props to pass to the rep.
+ */
+function getNodeRep(nodeFront, props = {}) {
+ const object = translateNodeFrontToGrip(nodeFront);
+ const { rep } = ElementNode.supportsObject(object) ? ElementNode : TextNode;
+
+ return rep({
+ object,
+ mode: MODE.TINY,
+ ...props,
+ });
+}
+
+module.exports = getNodeRep;
diff --git a/devtools/client/inspector/shared/node-types.js b/devtools/client/inspector/shared/node-types.js
new file mode 100644
index 0000000000..662bc0dd4e
--- /dev/null
+++ b/devtools/client/inspector/shared/node-types.js
@@ -0,0 +1,22 @@
+/* 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";
+
+/**
+ * Types of nodes used in the rule and computed view.
+ */
+
+exports.VIEW_NODE_CSS_QUERY_CONTAINER = "css-query-container";
+exports.VIEW_NODE_CSS_SELECTOR_WARNINGS = "css-selector-warnings";
+exports.VIEW_NODE_FONT_TYPE = "font-type";
+exports.VIEW_NODE_IMAGE_URL_TYPE = "image-url-type";
+exports.VIEW_NODE_INACTIVE_CSS = "inactive-css";
+exports.VIEW_NODE_LOCATION_TYPE = "location-type";
+exports.VIEW_NODE_PROPERTY_TYPE = "property-type";
+exports.VIEW_NODE_SELECTOR_TYPE = "selector-type";
+exports.VIEW_NODE_SHAPE_POINT_TYPE = "shape-point-type";
+exports.VIEW_NODE_SHAPE_SWATCH = "shape-swatch";
+exports.VIEW_NODE_VALUE_TYPE = "value-type";
+exports.VIEW_NODE_VARIABLE_TYPE = "variable-type";
diff --git a/devtools/client/inspector/shared/style-change-tracker.js b/devtools/client/inspector/shared/style-change-tracker.js
new file mode 100644
index 0000000000..348918cd56
--- /dev/null
+++ b/devtools/client/inspector/shared/style-change-tracker.js
@@ -0,0 +1,100 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+const WalkerEventListener = require("resource://devtools/client/inspector/shared/walker-event-listener.js");
+
+/**
+ * The InspectorStyleChangeTracker simply emits an event when it detects any changes in
+ * the page that may cause the current inspector selection to have different style applied
+ * to it.
+ * It currently tracks:
+ * - markup mutations, because they may cause different CSS rules to apply to the current
+ * node.
+ * - window resize, because they may cause media query changes and therefore also
+ * different CSS rules to apply to the current node.
+ */
+class InspectorStyleChangeTracker {
+ constructor(inspector) {
+ this.selection = inspector.selection;
+
+ this.onMutations = this.onMutations.bind(this);
+ this.onResized = this.onResized.bind(this);
+
+ this.walkerEventListener = new WalkerEventListener(inspector, {
+ mutations: this.onMutations,
+ resize: this.onResized,
+ });
+
+ EventEmitter.decorate(this);
+ }
+
+ destroy() {
+ this.walkerEventListener.destroy();
+ this.walkerEventListener = null;
+ this.selection = null;
+ }
+
+ /**
+ * When markup mutations occur, if an attribute of the selected node, one of its
+ * ancestors or siblings changes, we need to consider this as potentially causing a
+ * style change for the current node.
+ */
+ onMutations(mutations) {
+ const canMutationImpactCurrentStyles = ({
+ type,
+ target: mutationTarget,
+ }) => {
+ // Only attributes mutations are interesting here.
+ if (type !== "attributes") {
+ return false;
+ }
+
+ // Is the mutation on the current selected node?
+ const currentNode = this.selection.nodeFront;
+ if (mutationTarget === currentNode) {
+ return true;
+ }
+
+ // Is the mutation on one of the current selected node's siblings?
+ // We can't know the order of nodes on the client-side without calling
+ // walker.children, so don't attempt to check the previous or next element siblings.
+ // It's good enough to know that one sibling changed.
+ let parent = currentNode.parentNode();
+ const siblings = parent.treeChildren();
+ if (siblings.includes(mutationTarget)) {
+ return true;
+ }
+
+ // Is the mutation on one of the current selected node's parents?
+ while (parent) {
+ if (mutationTarget === parent) {
+ return true;
+ }
+ parent = parent.parentNode();
+ }
+
+ return false;
+ };
+
+ for (const mutation of mutations) {
+ if (canMutationImpactCurrentStyles(mutation)) {
+ this.emit("style-changed");
+ break;
+ }
+ }
+ }
+
+ /**
+ * When the window gets resized, this may cause media-queries to match, and we therefore
+ * need to consider this as a style change for the current node.
+ */
+ onResized() {
+ this.emit("style-changed");
+ }
+}
+
+module.exports = InspectorStyleChangeTracker;
diff --git a/devtools/client/inspector/shared/style-inspector-menu.js b/devtools/client/inspector/shared/style-inspector-menu.js
new file mode 100644
index 0000000000..e8eae77dae
--- /dev/null
+++ b/devtools/client/inspector/shared/style-inspector-menu.js
@@ -0,0 +1,502 @@
+/* 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 {
+ VIEW_NODE_SELECTOR_TYPE,
+ VIEW_NODE_PROPERTY_TYPE,
+ VIEW_NODE_VALUE_TYPE,
+ VIEW_NODE_IMAGE_URL_TYPE,
+ VIEW_NODE_LOCATION_TYPE,
+} = require("resource://devtools/client/inspector/shared/node-types.js");
+
+loader.lazyRequireGetter(
+ this,
+ "Menu",
+ "resource://devtools/client/framework/menu.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "MenuItem",
+ "resource://devtools/client/framework/menu-item.js"
+);
+loader.lazyRequireGetter(
+ this,
+ "getRuleFromNode",
+ "resource://devtools/client/inspector/rules/utils/utils.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "clipboardHelper",
+ "resource://devtools/shared/platform/clipboard.js"
+);
+
+const STYLE_INSPECTOR_PROPERTIES =
+ "devtools/shared/locales/styleinspector.properties";
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+const STYLE_INSPECTOR_L10N = new LocalizationHelper(STYLE_INSPECTOR_PROPERTIES);
+
+const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled";
+
+/**
+ * Style inspector context menu
+ *
+ * @param {RuleView|ComputedView} view
+ * RuleView or ComputedView instance controlling this menu
+ * @param {Object} options
+ * Option menu configuration
+ */
+function StyleInspectorMenu(view, { isRuleView = false } = {}) {
+ this.view = view;
+ this.inspector = this.view.inspector;
+ this.styleWindow = this.view.styleWindow || this.view.doc.defaultView;
+ this.isRuleView = isRuleView;
+
+ this._onCopy = this._onCopy.bind(this);
+ this._onCopyColor = this._onCopyColor.bind(this);
+ this._onCopyImageDataUrl = this._onCopyImageDataUrl.bind(this);
+ this._onCopyLocation = this._onCopyLocation.bind(this);
+ this._onCopyDeclaration = this._onCopyDeclaration.bind(this);
+ this._onCopyPropertyName = this._onCopyPropertyName.bind(this);
+ this._onCopyPropertyValue = this._onCopyPropertyValue.bind(this);
+ this._onCopyRule = this._onCopyRule.bind(this);
+ this._onCopySelector = this._onCopySelector.bind(this);
+ this._onCopyUrl = this._onCopyUrl.bind(this);
+ this._onSelectAll = this._onSelectAll.bind(this);
+ this._onToggleOrigSources = this._onToggleOrigSources.bind(this);
+}
+
+module.exports = StyleInspectorMenu;
+
+StyleInspectorMenu.prototype = {
+ /**
+ * Display the style inspector context menu
+ */
+ show(event) {
+ try {
+ this._openMenu({
+ target: event.target,
+ screenX: event.screenX,
+ screenY: event.screenY,
+ });
+ } catch (e) {
+ console.error(e);
+ }
+ },
+
+ _openMenu({ target, screenX = 0, screenY = 0 } = {}) {
+ this.currentTarget = target;
+ this.styleWindow.focus();
+
+ const menu = new Menu();
+
+ const menuitemCopy = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copy"),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copy.accessKey"
+ ),
+ click: () => {
+ this._onCopy();
+ },
+ disabled: !this._hasTextSelected(),
+ });
+ const menuitemCopyLocation = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copyLocation"
+ ),
+ click: () => {
+ this._onCopyLocation();
+ },
+ visible: false,
+ });
+ const menuitemCopyRule = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyRule"),
+ click: () => {
+ this._onCopyRule();
+ },
+ visible: this.isRuleView,
+ });
+ const copyColorAccessKey = "styleinspector.contextmenu.copyColor.accessKey";
+ const menuitemCopyColor = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copyColor"
+ ),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(copyColorAccessKey),
+ click: () => {
+ this._onCopyColor();
+ },
+ visible: this._isColorPopup(),
+ });
+ const copyUrlAccessKey = "styleinspector.contextmenu.copyUrl.accessKey";
+ const menuitemCopyUrl = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyUrl"),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(copyUrlAccessKey),
+ click: () => {
+ this._onCopyUrl();
+ },
+ visible: this._isImageUrl(),
+ });
+ const copyImageAccessKey =
+ "styleinspector.contextmenu.copyImageDataUrl.accessKey";
+ const menuitemCopyImageDataUrl = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copyImageDataUrl"
+ ),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(copyImageAccessKey),
+ click: () => {
+ this._onCopyImageDataUrl();
+ },
+ visible: this._isImageUrl(),
+ });
+ const copyDeclarationLabel = "styleinspector.contextmenu.copyDeclaration";
+ const menuitemCopyDeclaration = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(copyDeclarationLabel),
+ click: () => {
+ this._onCopyDeclaration();
+ },
+ visible: false,
+ });
+ const menuitemCopyPropertyName = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copyPropertyName"
+ ),
+ click: () => {
+ this._onCopyPropertyName();
+ },
+ visible: false,
+ });
+ const menuitemCopyPropertyValue = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copyPropertyValue"
+ ),
+ click: () => {
+ this._onCopyPropertyValue();
+ },
+ visible: false,
+ });
+ const menuitemCopySelector = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.copySelector"
+ ),
+ click: () => {
+ this._onCopySelector();
+ },
+ visible: false,
+ });
+
+ this._clickedNodeInfo = this._getClickedNodeInfo();
+ if (this.isRuleView && this._clickedNodeInfo) {
+ switch (this._clickedNodeInfo.type) {
+ case VIEW_NODE_PROPERTY_TYPE:
+ menuitemCopyDeclaration.visible = true;
+ menuitemCopyPropertyName.visible = true;
+ break;
+ case VIEW_NODE_VALUE_TYPE:
+ menuitemCopyDeclaration.visible = true;
+ menuitemCopyPropertyValue.visible = true;
+ break;
+ case VIEW_NODE_SELECTOR_TYPE:
+ menuitemCopySelector.visible = true;
+ break;
+ case VIEW_NODE_LOCATION_TYPE:
+ menuitemCopyLocation.visible = true;
+ break;
+ }
+ }
+
+ menu.append(menuitemCopy);
+ menu.append(menuitemCopyLocation);
+ menu.append(menuitemCopyRule);
+ menu.append(menuitemCopyColor);
+ menu.append(menuitemCopyUrl);
+ menu.append(menuitemCopyImageDataUrl);
+ menu.append(menuitemCopyDeclaration);
+ menu.append(menuitemCopyPropertyName);
+ menu.append(menuitemCopyPropertyValue);
+ menu.append(menuitemCopySelector);
+
+ menu.append(
+ new MenuItem({
+ type: "separator",
+ })
+ );
+
+ // Select All
+ const selectAllAccessKey = "styleinspector.contextmenu.selectAll.accessKey";
+ const menuitemSelectAll = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.selectAll"
+ ),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(selectAllAccessKey),
+ click: () => {
+ this._onSelectAll();
+ },
+ });
+ menu.append(menuitemSelectAll);
+
+ menu.append(
+ new MenuItem({
+ type: "separator",
+ })
+ );
+
+ // Add new rule
+ const addRuleAccessKey = "styleinspector.contextmenu.addNewRule.accessKey";
+ const menuitemAddRule = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.addNewRule"
+ ),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(addRuleAccessKey),
+ click: () => this.view._onAddRule(),
+ visible: this.isRuleView,
+ disabled: !this.isRuleView || this.inspector.selection.isAnonymousNode(),
+ });
+ menu.append(menuitemAddRule);
+
+ // Show Original Sources
+ const sourcesAccessKey =
+ "styleinspector.contextmenu.toggleOrigSources.accessKey";
+ const menuitemSources = new MenuItem({
+ label: STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.contextmenu.toggleOrigSources"
+ ),
+ accesskey: STYLE_INSPECTOR_L10N.getStr(sourcesAccessKey),
+ click: () => {
+ this._onToggleOrigSources();
+ },
+ type: "checkbox",
+ checked: Services.prefs.getBoolPref(PREF_ORIG_SOURCES),
+ });
+ menu.append(menuitemSources);
+
+ menu.popup(screenX, screenY, this.inspector.toolbox.doc);
+ return menu;
+ },
+
+ _hasTextSelected() {
+ let hasTextSelected;
+ const selection = this.styleWindow.getSelection();
+
+ const node = this._getClickedNode();
+ if (node.nodeName == "input" || node.nodeName == "textarea") {
+ const { selectionStart, selectionEnd } = node;
+ hasTextSelected =
+ isFinite(selectionStart) &&
+ isFinite(selectionEnd) &&
+ selectionStart !== selectionEnd;
+ } else {
+ hasTextSelected = selection.toString() && !selection.isCollapsed;
+ }
+
+ return hasTextSelected;
+ },
+
+ /**
+ * Get the type of the currently clicked node
+ */
+ _getClickedNodeInfo() {
+ const node = this._getClickedNode();
+ return this.view.getNodeInfo(node);
+ },
+
+ /**
+ * A helper that determines if the popup was opened with a click to a color
+ * value and saves the color to this._colorToCopy.
+ *
+ * @return {Boolean}
+ * true if click on color opened the popup, false otherwise.
+ */
+ _isColorPopup() {
+ this._colorToCopy = "";
+
+ const container = this._getClickedNode();
+ if (!container) {
+ return false;
+ }
+
+ const colorNode = container.closest("[data-color]");
+ if (!colorNode) {
+ return false;
+ }
+
+ this._colorToCopy = colorNode.dataset.color;
+ return true;
+ },
+
+ /**
+ * Check if the current node (clicked node) is an image URL
+ *
+ * @return {Boolean} true if the node is an image url
+ */
+ _isImageUrl() {
+ const nodeInfo = this._getClickedNodeInfo();
+ if (!nodeInfo) {
+ return false;
+ }
+ return nodeInfo.type == VIEW_NODE_IMAGE_URL_TYPE;
+ },
+
+ /**
+ * Get the DOM Node container for the current target node.
+ * If the target node is a text node, return the parent node, otherwise return
+ * the target node itself.
+ *
+ * @return {DOMNode}
+ */
+ _getClickedNode() {
+ const node = this.currentTarget;
+
+ if (!node) {
+ return null;
+ }
+
+ return node.nodeType === node.TEXT_NODE ? node.parentElement : node;
+ },
+
+ /**
+ * Select all text.
+ */
+ _onSelectAll() {
+ const selection = this.styleWindow.getSelection();
+
+ if (this.isRuleView) {
+ selection.selectAllChildren(
+ this.currentTarget.closest("#ruleview-container-focusable")
+ );
+ } else {
+ selection.selectAllChildren(this.view.element);
+ }
+ },
+
+ /**
+ * Copy the most recently selected color value to clipboard.
+ */
+ _onCopy() {
+ this.view.copySelection(this.currentTarget);
+ },
+
+ /**
+ * Copy the most recently selected color value to clipboard.
+ */
+ _onCopyColor() {
+ clipboardHelper.copyString(this._colorToCopy);
+ },
+
+ /*
+ * Retrieve the url for the selected image and copy it to the clipboard
+ */
+ _onCopyUrl() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ clipboardHelper.copyString(this._clickedNodeInfo.value.url);
+ },
+
+ /**
+ * Retrieve the image data for the selected image url and copy it to the
+ * clipboard
+ */
+ async _onCopyImageDataUrl() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ let message;
+ try {
+ const inspectorFront = this.inspector.inspectorFront;
+ const imageUrl = this._clickedNodeInfo.value.url;
+ const data = await inspectorFront.getImageDataFromURL(imageUrl);
+ message = await data.data.string();
+ } catch (e) {
+ message = STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.copyImageDataUrlError"
+ );
+ }
+
+ clipboardHelper.copyString(message);
+ },
+
+ /**
+ * Copy the rule source location of the current clicked node.
+ */
+ _onCopyLocation() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ clipboardHelper.copyString(this._clickedNodeInfo.value);
+ },
+
+ /**
+ * Copy the CSS declaration of the current clicked node.
+ */
+ _onCopyDeclaration() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ const textProp = this._clickedNodeInfo.value.textProperty;
+ clipboardHelper.copyString(textProp.stringifyProperty());
+ },
+
+ /**
+ * Copy the rule property name of the current clicked node.
+ */
+ _onCopyPropertyName() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ clipboardHelper.copyString(this._clickedNodeInfo.value.property);
+ },
+
+ /**
+ * Copy the rule property value of the current clicked node.
+ */
+ _onCopyPropertyValue() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ clipboardHelper.copyString(this._clickedNodeInfo.value.value);
+ },
+
+ /**
+ * Copy the rule of the current clicked node.
+ */
+ _onCopyRule() {
+ const node = this._getClickedNode();
+ const rule = getRuleFromNode(node, this.view._elementStyle);
+ clipboardHelper.copyString(rule.stringifyRule());
+ },
+
+ /**
+ * Copy the rule selector of the current clicked node.
+ */
+ _onCopySelector() {
+ if (!this._clickedNodeInfo) {
+ return;
+ }
+
+ clipboardHelper.copyString(this._clickedNodeInfo.value);
+ },
+
+ /**
+ * Toggle the original sources pref.
+ */
+ _onToggleOrigSources() {
+ const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES);
+ Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled);
+ },
+
+ destroy() {
+ this.currentTarget = null;
+ this.view = null;
+ this.inspector = null;
+ this.styleWindow = null;
+ },
+};
diff --git a/devtools/client/inspector/shared/test/browser.toml b/devtools/client/inspector/shared/test/browser.toml
new file mode 100644
index 0000000000..3cb858d97f
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser.toml
@@ -0,0 +1,44 @@
+[DEFAULT]
+tags = "devtools"
+subsuite = "devtools"
+support-files = [
+ "doc_content_style_changes.html",
+ "head.js",
+ "!/devtools/client/inspector/test/head.js",
+ "!/devtools/client/inspector/test/shared-head.js",
+ "!/devtools/client/shared/test/shared-head.js",
+ "!/devtools/client/shared/test/telemetry-test-helpers.js",
+ "!/devtools/client/shared/test/highlighter-test-actor.js",
+]
+
+["browser_styleinspector_context-menu-copy-color_01.js"]
+
+["browser_styleinspector_context-menu-copy-color_02.js"]
+
+["browser_styleinspector_context-menu-copy-urls.js"]
+
+["browser_styleinspector_output-parser.js"]
+
+["browser_styleinspector_refresh_when_active.js"]
+
+["browser_styleinspector_refresh_when_style_changes.js"]
+
+["browser_styleinspector_tooltip-background-image.js"]
+
+["browser_styleinspector_tooltip-closes-on-new-selection.js"]
+
+["browser_styleinspector_tooltip-longhand-fontfamily.js"]
+
+["browser_styleinspector_tooltip-multiple-background-images.js"]
+
+["browser_styleinspector_tooltip-shorthand-fontfamily.js"]
+
+["browser_styleinspector_tooltip-size.js"]
+
+["browser_styleinspector_transform-highlighter-01.js"]
+
+["browser_styleinspector_transform-highlighter-02.js"]
+
+["browser_styleinspector_transform-highlighter-03.js"]
+
+["browser_styleinspector_transform-highlighter-04.js"]
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_01.js b/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_01.js
new file mode 100644
index 0000000000..c1809dd543
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_01.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test "Copy color" item of the context menu #1: Test _isColorPopup.
+
+const TEST_URI = `
+ <div style="color:rgb(18, 58, 188);margin:0px;background:span[data-color];">
+ Test "Copy color" context menu option
+ </div>
+`;
+
+add_task(async function () {
+ // Test is slow on Linux EC2 instances - Bug 1137765
+ requestLongerTimeout(2);
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector } = await openInspector();
+ await testView("ruleview", inspector);
+ await testView("computedview", inspector);
+});
+
+async function testView(viewId, inspector) {
+ info("Testing " + viewId);
+
+ await inspector.sidebar.select(viewId);
+ const view =
+ inspector.getPanel(viewId).view || inspector.getPanel(viewId).computedView;
+ await selectNode("div", inspector);
+
+ testIsColorValueNode(view);
+ await clearCurrentNodeSelection(inspector);
+}
+
+/**
+ * A function testing that isColorValueNode correctly detects nodes part of
+ * color values.
+ */
+function testIsColorValueNode(view) {
+ info("Testing that child nodes of color nodes are detected.");
+ const root = rootElement(view);
+ const colorNode = root.querySelector("span[data-color]");
+
+ ok(colorNode, "Color node found");
+ for (const node of iterateNodes(colorNode)) {
+ ok(isColorValueNode(node), "Node is part of color value.");
+ }
+}
+
+/**
+ * Check if a node is part of color value i.e. it has parent with a 'data-color'
+ * attribute.
+ */
+function isColorValueNode(node) {
+ let container = node.nodeType == node.TEXT_NODE ? node.parentElement : node;
+
+ const isColorNode = el => el.dataset && "color" in el.dataset;
+
+ while (!isColorNode(container)) {
+ container = container.parentNode;
+ if (!container) {
+ info("No color. Node is not part of color value.");
+ return false;
+ }
+ }
+
+ info("Found a color. Node is part of color value.");
+
+ return true;
+}
+
+/**
+ * A generator that iterates recursively trough all child nodes of baseNode.
+ */
+function* iterateNodes(baseNode) {
+ yield baseNode;
+
+ for (const child of baseNode.childNodes) {
+ yield* iterateNodes(child);
+ }
+}
+
+/**
+ * Returns the root element for the given view, rule or computed.
+ */
+var rootElement = view => (view.element ? view.element : view.styleDocument);
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_02.js b/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_02.js
new file mode 100644
index 0000000000..546f3520d0
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-color_02.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test "Copy color" item of the context menu #2: Test that correct color is
+// copied if the color changes.
+
+const TEST_URI = `
+ <style type="text/css">
+ div {
+ color: #123ABC;
+ }
+ </style>
+ <div>Testing the color picker tooltip!</div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+
+ const { inspector, view } = await openRuleView();
+
+ await testCopyToClipboard(inspector, view);
+ await testManualEdit(inspector, view);
+ await testColorPickerEdit(inspector, view);
+});
+
+async function testCopyToClipboard(inspector, view) {
+ info("Testing that color is copied to clipboard");
+
+ await selectNode("div", inspector);
+
+ const element = getRuleViewProperty(
+ view,
+ "div",
+ "color"
+ ).valueSpan.querySelector(".ruleview-colorswatch");
+
+ const menu = view.contextMenu._openMenu({ target: element });
+ const allMenuItems = buildContextMenuItems(menu);
+ const menuitemCopyColor = allMenuItems.find(
+ item =>
+ item.label ===
+ STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyColor")
+ );
+
+ ok(menuitemCopyColor.visible, "Copy color is visible");
+
+ await waitForClipboardPromise(() => menuitemCopyColor.click(), "#123ABC");
+ ok(true, "expected color was copied to clipboard");
+
+ // close context menu
+ const onContextMenuClose = menu.once("close");
+ menu.hide(element.ownerDocument);
+ await onContextMenuClose;
+}
+
+async function testManualEdit(inspector, view) {
+ info("Testing manually edited colors");
+ await selectNode("div", inspector);
+
+ const colorTextProp = getTextProperty(view, 1, { color: "#123ABC" });
+ const newColor = "#C9184E";
+ await setProperty(view, colorTextProp, newColor);
+
+ const colorValueElement = await waitFor(() => {
+ const el = getRuleViewProperty(view, "div", "color").valueSpan.firstChild;
+ if (el?.dataset?.color !== newColor) {
+ return false;
+ }
+ return el;
+ });
+
+ ok(!!colorValueElement, "data-color was updated");
+
+ const contextMenu = view.contextMenu;
+ contextMenu.currentTarget = colorValueElement;
+ contextMenu._isColorPopup();
+
+ is(contextMenu._colorToCopy, newColor, "_colorToCopy has the new value");
+}
+
+async function testColorPickerEdit(inspector, view) {
+ info("Testing colors edited via color picker");
+ await selectNode("div", inspector);
+
+ const swatchElement = getRuleViewProperty(
+ view,
+ "div",
+ "color"
+ ).valueSpan.querySelector(".ruleview-colorswatch");
+
+ info("Opening the color picker");
+ const picker = view.tooltips.getTooltip("colorPicker");
+ const onColorPickerReady = picker.once("ready");
+ swatchElement.click();
+ await onColorPickerReady;
+
+ const newColor = "#53B759";
+ const { colorUtils } = require("resource://devtools/shared/css/color.js");
+
+ const { r, g, b, a } = new colorUtils.CssColor(newColor).getRGBATuple();
+ await simulateColorPickerChange(view, picker, [r, g, b, a]);
+
+ is(
+ swatchElement.parentNode.dataset.color,
+ newColor,
+ "data-color was updated"
+ );
+
+ const contextMenu = view.contextMenu;
+ contextMenu.currentTarget = swatchElement;
+ contextMenu._isColorPopup();
+
+ is(contextMenu._colorToCopy, newColor, "_colorToCopy has the new value");
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-urls.js b/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-urls.js
new file mode 100644
index 0000000000..69042cc747
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_context-menu-copy-urls.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* Tests both Copy URL and Copy Data URL context menu items */
+
+const TEST_DATA_URI =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs=";
+
+// Invalid URL still needs to be reachable otherwise getImageDataUrl will
+// timeout. DevTools chrome:// URLs aren't content accessible, so use some
+// random resource:// URL here.
+const INVALID_IMAGE_URI = "resource://devtools/client/definitions.js";
+const ERROR_MESSAGE = STYLE_INSPECTOR_L10N.getStr(
+ "styleinspector.copyImageDataUrlError"
+);
+
+add_task(async function () {
+ const TEST_URI = `<style type="text/css">
+ .valid-background {
+ background-image: url(${TEST_DATA_URI});
+ }
+ .invalid-background {
+ background-image: url(${INVALID_IMAGE_URI});
+ }
+ </style>
+ <div class="valid-background">Valid background image</div>
+ <div class="invalid-background">Invalid background image</div>`;
+
+ await addTab("data:text/html;charset=utf8," + encodeURIComponent(TEST_URI));
+
+ await startTest();
+});
+
+async function startTest() {
+ info("Opening rule view");
+ let { inspector, view } = await openRuleView();
+
+ info("Test valid background image URL in rule view");
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "data-uri",
+ ".valid-background",
+ TEST_DATA_URI
+ );
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "url",
+ ".valid-background",
+ TEST_DATA_URI
+ );
+
+ info("Test invalid background image URL in rue view");
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "data-uri",
+ ".invalid-background",
+ ERROR_MESSAGE
+ );
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "url",
+ ".invalid-background",
+ INVALID_IMAGE_URI
+ );
+
+ info("Opening computed view");
+ view = selectComputedView(inspector);
+
+ info("Test valid background image URL in computed view");
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "data-uri",
+ ".valid-background",
+ TEST_DATA_URI
+ );
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "url",
+ ".valid-background",
+ TEST_DATA_URI
+ );
+
+ info("Test invalid background image URL in computed view");
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "data-uri",
+ ".invalid-background",
+ ERROR_MESSAGE
+ );
+ await testCopyUrlToClipboard(
+ { view, inspector },
+ "url",
+ ".invalid-background",
+ INVALID_IMAGE_URI
+ );
+}
+
+async function testCopyUrlToClipboard(
+ { view, inspector },
+ type,
+ selector,
+ expected
+) {
+ info("Select node in inspector panel");
+ await selectNode(selector, inspector);
+
+ info(
+ "Retrieve background-image link for selected node in current " +
+ "styleinspector view"
+ );
+ const property = await getBackgroundImageProperty(view, selector);
+ const imageLink = property.valueSpan.querySelector(".theme-link");
+ ok(imageLink, "Background-image link element found");
+
+ info("Simulate right click on the background-image URL");
+ const allMenuItems = openStyleContextMenuAndGetAllItems(view, imageLink);
+ const menuitemCopyUrl = allMenuItems.find(
+ item =>
+ item.label ===
+ STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyUrl")
+ );
+ const menuitemCopyImageDataUrl = allMenuItems.find(
+ item =>
+ item.label ===
+ STYLE_INSPECTOR_L10N.getStr("styleinspector.contextmenu.copyImageDataUrl")
+ );
+
+ info("Context menu is displayed");
+ ok(menuitemCopyUrl.visible, '"Copy URL" menu entry is displayed');
+ ok(
+ menuitemCopyImageDataUrl.visible,
+ '"Copy Image Data-URL" menu entry is displayed'
+ );
+
+ if (type == "data-uri") {
+ info("Click Copy Data URI and wait for clipboard");
+ await waitForClipboardPromise(() => {
+ return menuitemCopyImageDataUrl.click();
+ }, expected);
+ } else {
+ info("Click Copy URL and wait for clipboard");
+ await waitForClipboardPromise(() => {
+ return menuitemCopyUrl.click();
+ }, expected);
+ }
+
+ info("Hide context menu");
+}
+
+async function getBackgroundImageProperty(view, selector) {
+ const isRuleView = view instanceof CssRuleView;
+ if (isRuleView) {
+ return getRuleViewProperty(view, selector, "background-image", {
+ wait: true,
+ });
+ }
+ return getComputedViewProperty(view, "background-image");
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js b/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
new file mode 100644
index 0000000000..1aa2879ee2
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_output-parser.js
@@ -0,0 +1,381 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test expected outputs of the output-parser's parseCssProperty function.
+
+// This is more of a unit test than a mochitest-browser test, but can't be
+// tested with an xpcshell test as the output-parser requires the DOM to work.
+
+const OutputParser = require("resource://devtools/client/shared/output-parser.js");
+
+const COLOR_CLASS = "color-class";
+const URL_CLASS = "url-class";
+const CUBIC_BEZIER_CLASS = "bezier-class";
+const ANGLE_CLASS = "angle-class";
+const LINEAR_EASING_CLASS = "linear-easing-class";
+
+const TEST_DATA = [
+ {
+ name: "width",
+ value: "100%",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ is(fragment.textContent, "100%");
+ },
+ },
+ {
+ name: "width",
+ value: "blue",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "content",
+ value: "'red url(test.png) repeat top left'",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "content",
+ value: '"blue"',
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "margin-left",
+ value: "url(something.jpg)",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "background-color",
+ value: "transparent",
+ test: fragment => {
+ is(countAll(fragment), 2);
+ is(countColors(fragment), 1);
+ is(fragment.textContent, "transparent");
+ },
+ },
+ {
+ name: "color",
+ value: "red",
+ test: fragment => {
+ is(countColors(fragment), 1);
+ is(fragment.textContent, "red");
+ },
+ },
+ {
+ name: "color",
+ value: "#F06",
+ test: fragment => {
+ is(countColors(fragment), 1);
+ is(fragment.textContent, "#F06");
+ },
+ },
+ {
+ name: "border",
+ value: "80em dotted pink",
+ test: fragment => {
+ is(countAll(fragment), 2);
+ is(countColors(fragment), 1);
+ is(getColor(fragment), "pink");
+ },
+ },
+ {
+ name: "color",
+ value: "red !important",
+ test: fragment => {
+ is(countColors(fragment), 1);
+ is(fragment.textContent, "red !important");
+ },
+ },
+ {
+ name: "background",
+ value: "red url(test.png) repeat top left",
+ test: fragment => {
+ is(countColors(fragment), 1);
+ is(countUrls(fragment), 1);
+ is(getColor(fragment), "red");
+ is(getUrl(fragment), "test.png");
+ is(countAll(fragment), 3);
+ },
+ },
+ {
+ name: "background",
+ value: "blue url(test.png) repeat top left !important",
+ test: fragment => {
+ is(countColors(fragment), 1);
+ is(countUrls(fragment), 1);
+ is(getColor(fragment), "blue");
+ is(getUrl(fragment), "test.png");
+ is(countAll(fragment), 3);
+ },
+ },
+ {
+ name: "list-style-image",
+ value: 'url("images/arrow.gif")',
+ test: fragment => {
+ is(countAll(fragment), 1);
+ is(getUrl(fragment), "images/arrow.gif");
+ },
+ },
+ {
+ name: "list-style-image",
+ value: 'url("images/arrow.gif")!important',
+ test: fragment => {
+ is(countAll(fragment), 1);
+ is(getUrl(fragment), "images/arrow.gif");
+ is(fragment.textContent, 'url("images/arrow.gif")!important');
+ },
+ },
+ {
+ name: "background",
+ value:
+ "linear-gradient(to right, rgba(183,222,237,1) 0%, " +
+ "rgba(33,180,226,1) 30%, rgba(31,170,217,.5) 44%, " +
+ "#F06 75%, red 100%)",
+ test: fragment => {
+ is(countAll(fragment), 10);
+ const allSwatches = fragment.querySelectorAll("." + COLOR_CLASS);
+ is(allSwatches.length, 5);
+ is(allSwatches[0].textContent, "rgba(183,222,237,1)");
+ is(allSwatches[1].textContent, "rgba(33,180,226,1)");
+ is(allSwatches[2].textContent, "rgba(31,170,217,.5)");
+ is(allSwatches[3].textContent, "#F06");
+ is(allSwatches[4].textContent, "red");
+ },
+ },
+ {
+ name: "background",
+ value:
+ "radial-gradient(circle closest-side at center, orange 0%, red 100%)",
+ test: fragment => {
+ is(countAll(fragment), 4);
+ const colorSwatches = fragment.querySelectorAll("." + COLOR_CLASS);
+ is(colorSwatches.length, 2);
+ is(colorSwatches[0].textContent, "orange");
+ is(colorSwatches[1].textContent, "red");
+ },
+ },
+ {
+ name: "background",
+ value: "white url(http://test.com/wow_such_image.png) no-repeat top left",
+ test: fragment => {
+ is(countAll(fragment), 3);
+ is(countUrls(fragment), 1);
+ is(countColors(fragment), 1);
+ },
+ },
+ {
+ name: "background",
+ value:
+ 'url("http://test.com/wow_such_(oh-noes)image.png?testid=1&color=red#w00t")',
+ test: fragment => {
+ is(countAll(fragment), 1);
+ is(
+ getUrl(fragment),
+ "http://test.com/wow_such_(oh-noes)image.png?testid=1&color=red#w00t"
+ );
+ },
+ },
+ {
+ name: "background-image",
+ value: "url(this-is-an-incredible-image.jpeg)",
+ test: fragment => {
+ is(countAll(fragment), 1);
+ is(getUrl(fragment), "this-is-an-incredible-image.jpeg");
+ },
+ },
+ {
+ name: "background",
+ value:
+ 'red url( "http://wow.com/cool/../../../you\'re(doingit)wrong" ) repeat center',
+ test: fragment => {
+ is(countAll(fragment), 3);
+ is(countColors(fragment), 1);
+ is(getUrl(fragment), "http://wow.com/cool/../../../you're(doingit)wrong");
+ },
+ },
+ {
+ name: "background-image",
+ value:
+ "url(../../../look/at/this/folder/structure/../" +
+ "../red.blue.green.svg )",
+ test: fragment => {
+ is(countAll(fragment), 1);
+ is(
+ getUrl(fragment),
+ "../../../look/at/this/folder/structure/../" + "../red.blue.green.svg"
+ );
+ },
+ },
+ {
+ name: "transition-timing-function",
+ value: "linear",
+ test: fragment => {
+ is(countCubicBeziers(fragment), 1);
+ is(getCubicBezier(fragment), "linear");
+ },
+ },
+ {
+ name: "animation-timing-function",
+ value: "ease-in-out",
+ test: fragment => {
+ is(countCubicBeziers(fragment), 1);
+ is(getCubicBezier(fragment), "ease-in-out");
+ },
+ },
+ {
+ name: "animation-timing-function",
+ value: "cubic-bezier(.1, 0.55, .9, -3.45)",
+ test: fragment => {
+ is(countCubicBeziers(fragment), 1);
+ is(getCubicBezier(fragment), "cubic-bezier(.1, 0.55, .9, -3.45)");
+ },
+ },
+ {
+ name: "animation-timing-function",
+ value: "CUBIC-BEZIER(.1, 0.55, .9, -3.45)",
+ test: fragment => {
+ is(countCubicBeziers(fragment), 1);
+ is(getCubicBezier(fragment), "CUBIC-BEZIER(.1, 0.55, .9, -3.45)");
+ },
+ },
+ {
+ name: "animation",
+ value: "move 3s cubic-bezier(.1, 0.55, .9, -3.45)",
+ test: fragment => {
+ is(countCubicBeziers(fragment), 1);
+ is(getCubicBezier(fragment), "cubic-bezier(.1, 0.55, .9, -3.45)");
+ },
+ },
+ {
+ name: "transition",
+ value: "top 1s ease-in",
+ test: fragment => {
+ is(countCubicBeziers(fragment), 1);
+ is(getCubicBezier(fragment), "ease-in");
+ },
+ },
+ {
+ name: "animation-timing-function",
+ value: "linear(0, 1 50% 100%)",
+ test: fragment => {
+ is(countLinears(fragment), 1);
+ is(getLinear(fragment), "linear(0, 1 50% 100%)");
+ },
+ },
+ {
+ name: "animation-timing-function",
+ value: "LINEAR(0, 1 50% 100%)",
+ test: fragment => {
+ is(countLinears(fragment), 1);
+ is(getLinear(fragment), "LINEAR(0, 1 50% 100%)");
+ },
+ },
+ {
+ name: "transition",
+ value: "top 3s steps(4, end)",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "transition",
+ value: "top 3s step-start",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "transition",
+ value: "top 3s step-end",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ },
+ },
+ {
+ name: "background",
+ value: "rgb(255, var(--g-value), 192)",
+ test: fragment => {
+ is(fragment.textContent, "rgb(255, var(--g-value), 192)");
+ },
+ },
+ {
+ name: "background",
+ value: "rgb(255, var(--g-value, 0), 192)",
+ test: fragment => {
+ is(fragment.textContent, "rgb(255, var(--g-value, 0), 192)");
+ },
+ },
+ {
+ name: "--url",
+ value: "url(())",
+ test: fragment => {
+ is(countAll(fragment), 0);
+ is(fragment.textContent, "url(())");
+ },
+ },
+];
+
+add_task(async function () {
+ const cssProperties = getClientCssProperties();
+ const parser = new OutputParser(document, cssProperties);
+ for (let i = 0; i < TEST_DATA.length; i++) {
+ const data = TEST_DATA[i];
+ info(
+ "Output-parser test data " +
+ i +
+ ". {" +
+ data.name +
+ " : " +
+ data.value +
+ ";}"
+ );
+ data.test(
+ parser.parseCssProperty(data.name, data.value, {
+ colorClass: COLOR_CLASS,
+ urlClass: URL_CLASS,
+ bezierClass: CUBIC_BEZIER_CLASS,
+ angleClass: ANGLE_CLASS,
+ linearEasingClass: LINEAR_EASING_CLASS,
+ })
+ );
+ }
+});
+
+function countAll(fragment) {
+ return fragment.querySelectorAll("*").length;
+}
+function countColors(fragment) {
+ return fragment.querySelectorAll("." + COLOR_CLASS).length;
+}
+function countUrls(fragment) {
+ return fragment.querySelectorAll("." + URL_CLASS).length;
+}
+function countCubicBeziers(fragment) {
+ return fragment.querySelectorAll("." + CUBIC_BEZIER_CLASS).length;
+}
+function countLinears(fragment) {
+ return fragment.querySelectorAll("." + LINEAR_EASING_CLASS).length;
+}
+function getColor(fragment, index) {
+ return fragment.querySelectorAll("." + COLOR_CLASS)[index || 0].textContent;
+}
+function getUrl(fragment, index) {
+ return fragment.querySelectorAll("." + URL_CLASS)[index || 0].textContent;
+}
+function getCubicBezier(fragment, index) {
+ return fragment.querySelectorAll("." + CUBIC_BEZIER_CLASS)[index || 0]
+ .textContent;
+}
+function getLinear(fragment, index = 0) {
+ return fragment.querySelectorAll("." + LINEAR_EASING_CLASS)[index]
+ .textContent;
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_active.js b/devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_active.js
new file mode 100644
index 0000000000..de2f3cc3e6
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_active.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the rule and computed view refreshes when they are active.
+
+const TEST_URI = `
+ <div id="one" style="color:red;">one</div>
+ <div id="two" style="color:blue;">two</div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+
+ await selectNode("#one", inspector);
+
+ is(
+ getRuleViewPropertyValue(view, "element", "color"),
+ "red",
+ "The rule-view shows the properties for test node one"
+ );
+
+ info("Switching to the computed-view");
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ selectComputedView(inspector);
+ await onComputedViewReady;
+ const cView = inspector.getPanel("computedview").computedView;
+
+ is(
+ getComputedViewPropertyValue(cView, "color"),
+ "rgb(255, 0, 0)",
+ "The computed-view shows the properties for test node one"
+ );
+
+ info("Selecting test node two");
+ await selectNode("#two", inspector);
+
+ is(
+ getComputedViewPropertyValue(cView, "color"),
+ "rgb(0, 0, 255)",
+ "The computed-view shows the properties for test node two"
+ );
+ is(
+ getRuleViewPropertyValue(view, "element", "color"),
+ "blue",
+ "The rule-view shows the properties for test node two"
+ );
+});
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_style_changes.js b/devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_style_changes.js
new file mode 100644
index 0000000000..9b92e65c07
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_refresh_when_style_changes.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the rule and computed views refresh when style changes that impact the
+// current selection occur.
+// This test does not need to worry about the correctness of the styles and rules
+// displayed in these views (other tests do this) but only cares that they do catch the
+// change.
+
+const TEST_URI = TEST_URL_ROOT + "doc_content_style_changes.html";
+
+const TEST_DATA = [
+ {
+ target: "#test",
+ className: "green-class",
+ force: true,
+ },
+ {
+ target: "#test",
+ className: "green-class",
+ force: false,
+ },
+ {
+ target: "#parent",
+ className: "purple-class",
+ force: true,
+ },
+ {
+ target: "#parent",
+ className: "purple-class",
+ force: false,
+ },
+ {
+ target: "#sibling",
+ className: "blue-class",
+ force: true,
+ },
+ {
+ target: "#sibling",
+ className: "blue-class",
+ force: false,
+ },
+];
+
+add_task(async function () {
+ const tab = await addTab(TEST_URI);
+
+ const { inspector } = await openRuleView();
+ await selectNode("#test", inspector);
+
+ info("Run the test on the rule-view");
+ await runViewTest(inspector, tab, "rule");
+
+ info("Switch to the computed view");
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ selectComputedView(inspector);
+ await onComputedViewReady;
+
+ info("Run the test again on the computed view");
+ await runViewTest(inspector, tab, "computed");
+});
+
+async function runViewTest(inspector, tab, viewName) {
+ for (const { target, className, force } of TEST_DATA) {
+ info(
+ (force ? "Adding" : "Removing") +
+ ` class ${className} on ${target} and expecting a ${viewName}-view refresh`
+ );
+
+ await toggleClassAndWaitForViewChange(
+ { target, className, force },
+ inspector,
+ tab,
+ `${viewName}-view-refreshed`
+ );
+ }
+}
+
+async function toggleClassAndWaitForViewChange(
+ whatToMutate,
+ inspector,
+ tab,
+ eventName
+) {
+ const onRefreshed = inspector.once(eventName);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [whatToMutate],
+ function ({ target, className, force }) {
+ content.document.querySelector(target).classList.toggle(className, force);
+ }
+ );
+
+ await onRefreshed;
+ ok(true, "The view was refreshed after the class was changed");
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-background-image.js b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-background-image.js
new file mode 100644
index 0000000000..638648d78e
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-background-image.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that background-image URLs have image preview tooltips in the rule-view
+// and computed-view
+
+const TEST_URI = `
+ <style type="text/css">
+ body {
+ padding: 1em;
+ background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAADI5JREFUeNrsWwuQFNUVPf1m5z87szv7HWSWj8CigBFMEFZKiQsB1PgJwUAZg1HBpIQsKmokEhNjWUnFVPnDWBT+KolJYbRMoqUVq0yCClpqiX8sCchPWFwVlt2db7+X93pez7zu6Vn2NxsVWh8987p7pu+9555z7+tZjTGGY3kjOMa34w447oBjfKsY7i/UNM3Y8eFSAkD50Plgw03K5P9gvGv7U5ieeR3PszeREiPNX3/0DL4hjslzhm8THh+OITfXk3dhiv4GDtGPVzCaeJmPLYzuu5qJuWfuw2QTlcN1X9pwQU7LhdZ/ZAseD45cOh9hHvDkc/yAF/DNhdb5Mrr3PvBMaAYW8fMSIi2G497IMEK/YutGtAYr6+ej+nxu/NN8Ks3N7AR6HgcLz0Eg1Ljg1UcxZzi5qewIkMYLRweTr2Kzp+nmyXAd5pS3XQDd+N/4h4zgu9FI7brlXf90nMEnuwQxlvv+hosE3TuexmWeysmT4W+WxkMaLzf9Y8ATgjcUn7T9H1gqrpFq8eV1gMn6t16NhngjfoX6q4DUP032Rd4LJgpSLwJ1yzFqBG69eRkah0MVyo0Acfe+yy9AG4nMiYCkeM53KKFXncBLAXqEm+wCqZwaueq7WCmuLTcKSJmj737ol2hurA9eq9VdyiO8yWa3NNyog+SB5CZodSsQq/dfu34tJpYbBaTMzvVddDZu16q5smXf4G8zEvqm4cyaAmJPuTJk3oJWdS4WzcVtfMZbThSQckb/pYfRGgo3zNOqZnEHbJPGK4abaDCQIIsT8V/qTaBqHkLh6LzXH8XZQhbLhYKyyCC/WeHYcNdmvOgfe8skzbWL270/T3wf7tSx/lGCbTu8xlzzmCSWLc5iwmgikcCHi3Mga0Ry913vBFvQwg90l6M4ImWKfsWOp7DSWxmfpPlCFuPFfsNfKrCnPYpQKIRgqBK7D0SxYaNHwkEiJMtl0ReDp3Lc5D3PGoTo/sKngCl7a5chFqvBatKwjBd7WwqIlzB/78NcoUcp5VSgGxm+7b8eqQRGnHMO634epO4S1EZww09/iFg5UmGoESDuznP1xVhTUX1WWHPzjpd25wyH0hRxI3LGM75nxmuNEEUVpAN0XgxmPoKralakbQnWlIMQyVBD/w+3orkq4lvualjKyWwzt4MaxqspQHVhPOWG64bxYuhZXSFGWhipbSDVragOu5Y9eAsmDDUKyBA703vemVhHoueD6e9wAzJK1WfmN0Umk5GGM4kEMZcuIECqgjm0nldAqmbjwtm4VxZH5AvlADP6mx9Eqy9Q0+KqW8Ch+47FaMMYmnNGfY1iPMshoC6qFxme4wQ+0p+ARE6H3+9veWEDWgUhDhUKyFARn4jM5BNxT0XsMg7bfymGK1ov3wtjDfhL4w0HVGUVBEjDaaE+QNdrcNWch1PG4W6xrjBUXECGivg++Cva3JUT4iQUz3V2RsSVaKLwOuDT89A3HdBQoxhNC+fnVm74ual2EG893P6G+PuP4SfiO4cCBWQooL9qCWKNXPbcI37Aa/lnlZxXRt4RFONGwSDCPAHqOuqjWct1QiEMw5mChM5X4K47FyNqcd3aK9AwFH0CGYLoe1ctxk2eWi57rg5JfGp9rzC6ggCdFlAgHBDw5Yxlcg6G8SyHCjMlsgmDD9zhSeHlF+JnAgWDTQUy2NxfdwOao1UVV3pi3+bE97YSbWpLAbn6zefHNQkp1PMpIBwwvslKgIYTKM2nEpNzrGcH3FXTEal0L38kJ4uDQgEZbO4vnI173LXf5NHZaiUxtaCxyZuo/rK6LpUg54yg3zTWRAArvDcRIPZ6BqzrQ1REpmL+DNw32OKIDCb3X1qPVn8wNNMT4w2bvs+q4bAZrqBh2skaL3yyhhIIZ4i6oHkUK0RckcB8GigEyRIH4A6Mgc8fatl0/+BkkQxC9gIT4ljna1rIZW9rEdNbjJcNjsnoYj7LHWCUwpITzEgzRQKZ3XAFHbTzA3hrz8TEUUZxFBhoKpABQt/97p+w0hMZG68I8R6FtlsJT3FELndZntjM+VMnylKYq8GJI3UZaRMpquGSGFVOEfv0YZBMNzz+uvjbfzS6xQERIhlI9FcvQWNdFVb7x1zCb+QNK8vb9NsiifmI5hBgVoOCBC1sb0ab5RomqENxLO3eA1/0NDRU47q2RQNbRCUDIb7lF2CNL3ZGxEV4n08TVvZWYG4pZyV0zUdS45tyCBByOHWiyvZmxFXDCyRo1ge5+Sy0TA+8lWMiP/6O0S32exGV9Jf4fr8azdUR3zL/CZz4MtvzdX5uOYs6NDOmpkuj5Huh+7qUQSYl0ThHzw0YQzcGo6bhzEqoYq5rN3yRiYiG3Vfe2Ybm/qKA9NNZ3nNm4F7/yDkg9AN+U1mHiBcXP8zuDN76jj8hg1QyiWQigalj02BJPhK8I0zxijAjhp5zhlpLUDvS+BCy2HMAvvB4XDgL9/SXC0g/ou/5+6/xLX8w0uJrOIkXfPvyhY0F6gr7M8H0KWFYikcqAXakB+xwD9CdREBLoau7Gz3cAdSIdLFxFtJTCqRChSjnutvhDcREtzjz2Tswtz+yeNRFUeXZXtWux7C1fuoVcbd3J//ipDX3uZZDLGrwweS+UBLL5TDliVBnF8P7H+XI8aRRGsIBJg/Zlslt1+W+D1JWoSyi+kD9jfhs78t7mhZhSl+fLfY1Bdyv3I8V/qpY3B1McgN7ZFT5/vNO0I5DPLLdPBIJA8qc4h2I0QplYfDpJwHT+aj0246r5S8rToG8OjCle8wk4OLvvYGa+Ovr84uo2qBSwJS9G5egoZFLTfiEqWDtbwGfHgKOdPHcS+ai7XDzMPW/FJRLGGcxnBbK4YJC2K+h+T6Bdu5CqHqCWERd3bawb7JI+iJ735+LNaHaprBLLHBm08U3XxShEsdt+f3eTh3v7aC95Dct4RCWL5OZWh/oXBZThxAIxyOXLzBk8aiEWJID8rK3CpPOmeHaGpvCS+7EHv5FujVHUSJPLXvIFeHcNc+9xrB2gws9KZdxuLFax/WLM5gzzSm/lTXF/OdAcapyvjxPqxqHjr2v4ckX2bS2dRBrc5lSdpKjEJ9/9tdwX2WMd53ZQ2IVo3RES+UwVSpCPvYepNx4gmTGDUKIMQ4eduPnD7mx9xOn/KZKOlFbStjONxHTtR+BYAPmnoZ1Zp8wkBRwP/EL3u0F/C2hGl7vpz7vW37T3vP7if8wroKuoh8ribknX9BK5rcF+mo1qKaKyRPJTgTDjbzY8szcuLb3bpH00u35T47j7prRpwDJTxzyG0dHgxPp5bPG8VdkpfPbUg3SgoOo2mwVukb98D5EqpswZTTulCggTk4gpYhv0++wIhCJxr0+Hq1sondis0SE2oxQe3qWXwWyO4DSQg9gJ8Iiw1VFcGqXxet0N9xE4ygIxv/9W6wo9WyROEX/R+eiobYSq2vHTOR631Eiv2lRfh9dvxkumkXh92Qsx8XrAJ+7YGbWuhxOi/U+31NQmzyqNYG8N/3wfo6CRtRHcN01FzkvojohwLu0VVvDa56IS/xcj2b7nN+O+m0jqpE1wMPXZxAN9iCVThtDvH7gmiRGRpU8Lspv1Uhq4wIVdQoyuGSLNYPKUCS8+CzNURbzMmjK3i8u0U793lmuV0ef9nWQ5MGC/DiUqEUSaCtXna9RJEspZS1lrXINK/pcq+SpT50t98QKMq1FRmDfx3vxty102k0PM4ssEnvuz5+G26Ij4yDpz6z9fV8bkyIkqBFkhej0Ib+ZQ34XJK9AfozaiimqIoX3Jp3tiISrcfYpuN2+iFph/02P36PNC9fVcCnp6H9jYouKyfaWufz5Tp9tVxcUniw7IohZv4dZz81/ns67z3AYPrc2n0+Ix2q8k0PWjgBy88XaibnfK9A+5LdDY2Ivhy36fbT8Zv3Lb1U1qLqUxorXEEXIs0mjjrtxoTZWtdvigNs2sgPiujTv6DIZLld6b/V5742JZV3fUsUVFy5gdsNtKWFzUCEVbNepD1MkSMVbsb6SZm7jI3/zODtQKgUMsOw8wDZ63t5xcV1TnaEAxoc6wrqY+Fj+N4DsqOnhOIdicrQSm1MPYCPlIqHn5bbHg8/bj2D3QfZnCX3mpAICDZV8jH5kpbZqTD0W+DxaA74CWzLN2nd14OlL72J38Lf7+TjC7dadZFDoZJQPrtaIKL/G0L6ktptPZVJ8fMqHYPZOKYPMyQGadIJfDvdXwAFiZOTvDBPydf5vk4rWA+RfdhBlaF/yDDBRoMu9pfnSjv/p7DG+HXfAcQcc49v/BBgAcFAO4DmB2GQAAAAASUVORK5CYII=);
+ background-repeat: repeat-y;
+ background-position: right top;
+ }
+ .test-element {
+ font-family: verdana;
+ color: #333;
+ background: url(chrome://global/skin/icons/help.svg) no-repeat left center;
+ padding-left: 70px;
+ }
+ </style>
+ <div class="test-element">test element</div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ let { inspector, view } = await openRuleView();
+
+ info("Testing the background-image property on the body rule");
+ await testBodyRuleView(view);
+
+ info("Selecting the test div node");
+ await selectNode(".test-element", inspector);
+ info("Testing the the background property on the .test-element rule");
+ await testDivRuleView(view);
+
+ info(
+ "Testing that image preview tooltips show even when there are " +
+ "fields being edited"
+ );
+ await testTooltipAppearsEvenInEditMode(view);
+
+ info("Switching over to the computed-view");
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ view = selectComputedView(inspector);
+ await onComputedViewReady;
+
+ info("Testing that the background-image computed style has a tooltip too");
+ await testComputedView(view);
+});
+
+async function testBodyRuleView(view) {
+ info("Testing tooltips in the rule view");
+
+ // XXX we have an intermittent here (Bug 1743594) where the rule view is still empty
+ // at this point. We're currently investigating what's going on and a proper way to
+ // wait in openRuleView, but for now, let's fix the intermittent by waiting until the
+ // rule view has the expected content.
+ const property = await waitFor(() =>
+ getRuleViewProperty(view, "body", "background-image")
+ );
+
+ // Get the background-image property inside the rule view
+ const { valueSpan } = property;
+ const uriSpan = valueSpan.querySelector(".theme-link");
+
+ const previewTooltip = await assertShowPreviewTooltip(view, uriSpan);
+
+ const images = previewTooltip.panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0]
+ .getAttribute("src")
+ .includes("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHe"),
+ "The image URL seems fine"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, uriSpan);
+}
+
+async function testDivRuleView(view) {
+ // Get the background property inside the rule view
+ const { valueSpan } = getRuleViewProperty(
+ view,
+ ".test-element",
+ "background"
+ );
+ const uriSpan = valueSpan.querySelector(".theme-link");
+
+ const previewTooltip = await assertShowPreviewTooltip(view, uriSpan);
+
+ const images = previewTooltip.panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri image as expected"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, uriSpan);
+}
+
+async function testTooltipAppearsEvenInEditMode(view) {
+ info("Switching to edit mode in the rule view");
+ const editor = await turnToEditMode(view);
+
+ info("Now trying to show the preview tooltip");
+ const { valueSpan } = getRuleViewProperty(
+ view,
+ ".test-element",
+ "background"
+ );
+ const uriSpan = valueSpan.querySelector(".theme-link");
+
+ const previewTooltip = await assertShowPreviewTooltip(view, uriSpan);
+
+ is(
+ view.styleDocument.activeElement,
+ editor.input,
+ "Tooltip was shown in edit mode, and inplace-editor still focused"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, uriSpan);
+}
+
+function turnToEditMode(ruleView) {
+ const brace = ruleView.styleDocument.querySelector(".ruleview-ruleclose");
+ return focusEditableField(ruleView, brace);
+}
+
+async function testComputedView(view) {
+ const { valueSpan } = getComputedViewProperty(view, "background-image");
+ const uriSpan = valueSpan.querySelector(".theme-link");
+
+ // Scroll to ensure the line is visible as we see the box model by default
+ valueSpan.scrollIntoView();
+
+ const previewTooltip = await assertShowPreviewTooltip(view, uriSpan);
+
+ const images = previewTooltip.panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri in the computed-view too"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, uriSpan);
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-closes-on-new-selection.js b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-closes-on-new-selection.js
new file mode 100644
index 0000000000..3fe58aa63d
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-closes-on-new-selection.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that if a tooltip is visible when a new selection is made, it closes
+
+const TEST_URI = "<div class='one'>el 1</div><div class='two'>el 2</div>";
+const XHTML_NS = "http://www.w3.org/1999/xhtml";
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ let { inspector, view } = await openRuleView();
+ await selectNode(".one", inspector);
+
+ info("Testing rule view tooltip closes on new selection");
+ await testRuleView(view, inspector);
+
+ info("Testing computed view tooltip closes on new selection");
+ view = selectComputedView(inspector);
+ await testComputedView(view, inspector);
+});
+
+async function testRuleView(ruleView, inspector) {
+ info("Showing the tooltip");
+
+ const tooltip = ruleView.tooltips.getTooltip("previewTooltip");
+ const tooltipContent = ruleView.styleDocument.createElementNS(
+ XHTML_NS,
+ "div"
+ );
+ tooltip.panel.appendChild(tooltipContent);
+ tooltip.setContentSize({ width: 100, height: 30 });
+
+ // Stop listening for mouse movements because it's not needed for this test,
+ // and causes intermittent failures on Linux. When this test runs in the suite
+ // sometimes a mouseleave event is dispatched at the start, which causes the
+ // tooltip to hide in the middle of being shown, which causes timeouts later.
+ tooltip.stopTogglingOnHover();
+
+ const onShown = tooltip.once("shown");
+ tooltip.show(ruleView.styleDocument.firstElementChild);
+ await onShown;
+
+ info("Selecting a new node");
+ const onHidden = tooltip.once("hidden");
+ await selectNode(".two", inspector);
+ await onHidden;
+
+ ok(true, "Rule view tooltip closed after a new node got selected");
+}
+
+async function testComputedView(computedView, inspector) {
+ info("Showing the tooltip");
+
+ const tooltip = computedView.tooltips.getTooltip("previewTooltip");
+ const tooltipContent = computedView.styleDocument.createElementNS(
+ XHTML_NS,
+ "div"
+ );
+ tooltip.panel.appendChild(tooltipContent);
+ await tooltip.setContentSize({ width: 100, height: 30 });
+
+ // Stop listening for mouse movements because it's not needed for this test,
+ // and causes intermittent failures on Linux. When this test runs in the suite
+ // sometimes a mouseleave event is dispatched at the start, which causes the
+ // tooltip to hide in the middle of being shown, which causes timeouts later.
+ tooltip.stopTogglingOnHover();
+
+ const onShown = tooltip.once("shown");
+ tooltip.show(computedView.styleDocument.firstElementChild);
+ await onShown;
+
+ info("Selecting a new node");
+ const onHidden = tooltip.once("hidden");
+ await selectNode(".one", inspector);
+ await onHidden;
+
+ ok(true, "Computed view tooltip closed after a new node got selected");
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-longhand-fontfamily.js b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-longhand-fontfamily.js
new file mode 100644
index 0000000000..8a31e94918
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-longhand-fontfamily.js
@@ -0,0 +1,178 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the fontfamily tooltip on longhand properties
+
+const TEST_URI = `
+ <style type="text/css">
+ #testElement {
+ font-family: cursive;
+ color: #333;
+ padding-left: 70px;
+ }
+ </style>
+ <div id="testElement">test element</div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ let { inspector, view } = await openRuleView();
+ await selectNode("#testElement", inspector);
+ await testRuleView(view, inspector.selection.nodeFront);
+
+ info("Opening the computed view");
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ view = selectComputedView(inspector);
+ await onComputedViewReady;
+
+ await testComputedView(view, inspector.selection.nodeFront);
+
+ await testExpandedComputedViewProperty(view, inspector.selection.nodeFront);
+});
+
+async function testRuleView(ruleView, nodeFront) {
+ info("Testing font-family tooltips in the rule view");
+
+ const tooltip = ruleView.tooltips.getTooltip("previewTooltip");
+ const panel = tooltip.panel;
+
+ // Check that the rule view has a tooltip and that a XUL panel has
+ // been created
+ ok(tooltip, "Tooltip instance exists");
+ ok(panel, "XUL panel exists");
+
+ // Get the font family property inside the rule view
+ const { valueSpan } = getRuleViewProperty(
+ ruleView,
+ "#testElement",
+ "font-family"
+ );
+
+ // And verify that the tooltip gets shown on this property
+ valueSpan.scrollIntoView(true);
+ let previewTooltip = await assertShowPreviewTooltip(ruleView, valueSpan);
+
+ let images = panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri image as expected"
+ );
+
+ let dataURL = await getFontFamilyDataURL(valueSpan.textContent, nodeFront);
+ is(
+ images[0].getAttribute("src"),
+ dataURL,
+ "Tooltip contains the correct data-uri image"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, valueSpan);
+
+ // Do the tooltip test again, but now when hovering on the span that
+ // encloses each and every font family.
+ const fontFamilySpan = valueSpan.querySelector(".ruleview-font-family");
+ fontFamilySpan.scrollIntoView(true);
+
+ previewTooltip = await assertShowPreviewTooltip(ruleView, fontFamilySpan);
+
+ images = panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri image as expected"
+ );
+
+ dataURL = await getFontFamilyDataURL(fontFamilySpan.textContent, nodeFront);
+ is(
+ images[0].getAttribute("src"),
+ dataURL,
+ "Tooltip contains the correct data-uri image"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, fontFamilySpan);
+}
+
+async function testComputedView(computedView, nodeFront) {
+ info("Testing font-family tooltips in the computed view");
+
+ const tooltip = computedView.tooltips.getTooltip("previewTooltip");
+ const panel = tooltip.panel;
+ const { valueSpan } = getComputedViewProperty(computedView, "font-family");
+
+ valueSpan.scrollIntoView(true);
+ const previewTooltip = await assertShowPreviewTooltip(
+ computedView,
+ valueSpan
+ );
+
+ const images = panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri image as expected"
+ );
+
+ const dataURL = await getFontFamilyDataURL(valueSpan.textContent, nodeFront);
+ is(
+ images[0].getAttribute("src"),
+ dataURL,
+ "Tooltip contains the correct data-uri image"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, valueSpan);
+}
+
+async function testExpandedComputedViewProperty(computedView, nodeFront) {
+ info(
+ "Testing font-family tooltips in expanded properties of the " +
+ "computed view"
+ );
+
+ info("Expanding the font-family property to reveal matched selectors");
+ const propertyView = getPropertyView(computedView, "font-family");
+ propertyView.matchedExpanded = true;
+ await propertyView.refreshMatchedSelectors();
+
+ const valueSpan = propertyView.matchedSelectorsContainer.querySelector(
+ ".bestmatch .computed-other-property-value"
+ );
+
+ const tooltip = computedView.tooltips.getTooltip("previewTooltip");
+ const panel = tooltip.panel;
+
+ valueSpan.scrollIntoView(true);
+ const previewTooltip = await assertShowPreviewTooltip(
+ computedView,
+ valueSpan
+ );
+
+ const images = panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri image as expected"
+ );
+
+ const dataURL = await getFontFamilyDataURL(valueSpan.textContent, nodeFront);
+ is(
+ images[0].getAttribute("src"),
+ dataURL,
+ "Tooltip contains the correct data-uri image"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, valueSpan);
+}
+
+function getPropertyView(computedView, name) {
+ let propertyView = null;
+ computedView.propertyViews.some(function (view) {
+ if (view.name == name) {
+ propertyView = view;
+ return true;
+ }
+ return false;
+ });
+ return propertyView;
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-multiple-background-images.js b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-multiple-background-images.js
new file mode 100644
index 0000000000..5caab37555
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-multiple-background-images.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+// Test for bug 1026921: Ensure the URL of hovered url() node is used instead
+// of the first found from the declaration as there might be multiple urls.
+
+const YELLOW_DOT =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3gYcDCwCr0o5ngAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAANSURBVAjXY/j/n6EeAAd9An7Z55GEAAAAAElFTkSuQmCC";
+const BLUE_DOT =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3gYcDCwlCkCM9QAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAANSURBVAjXY2Bg+F8PAAKCAX/tPkrkAAAAAElFTkSuQmCC";
+const TEST_STYLE = `h1 {background: url(${YELLOW_DOT}), url(${BLUE_DOT});}`;
+const TEST_URI = `<style>${TEST_STYLE}</style><h1>test element</h1>`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector } = await openInspector();
+
+ await testRuleViewUrls(inspector);
+ await testComputedViewUrls(inspector);
+});
+
+async function testRuleViewUrls(inspector) {
+ info("Testing tooltips in the rule view");
+ const view = selectRuleView(inspector);
+ await selectNode("h1", inspector);
+
+ const { valueSpan } = getRuleViewProperty(view, "h1", "background");
+ await performChecks(view, valueSpan);
+}
+
+async function testComputedViewUrls(inspector) {
+ info("Testing tooltips in the computed view");
+
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ const view = selectComputedView(inspector);
+ await onComputedViewReady;
+
+ const { valueSpan } = getComputedViewProperty(view, "background-image");
+
+ await performChecks(view, valueSpan);
+}
+
+/**
+ * A helper that checks url() tooltips contain correct images
+ */
+async function performChecks(view, propertyValue) {
+ function checkTooltip(panel, imageSrc) {
+ const images = panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ is(images[0].getAttribute("src"), imageSrc, "The image URL is correct");
+ }
+
+ const links = propertyValue.querySelectorAll(".theme-link");
+
+ info("Checking first link tooltip");
+ let previewTooltip = await assertShowPreviewTooltip(view, links[0]);
+ const panel = view.tooltips.getTooltip("previewTooltip").panel;
+ checkTooltip(panel, YELLOW_DOT);
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, links[0]);
+
+ info("Checking second link tooltip");
+ previewTooltip = await assertShowPreviewTooltip(view, links[1]);
+ checkTooltip(panel, BLUE_DOT);
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, links[1]);
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-shorthand-fontfamily.js b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-shorthand-fontfamily.js
new file mode 100644
index 0000000000..ef98a20a37
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-shorthand-fontfamily.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the fontfamily tooltip on shorthand properties
+
+const TEST_URI = `
+ <style type="text/css">
+ #testElement {
+ font: italic bold .8em/1.2 Arial;
+ }
+ </style>
+ <div id="testElement">test element</div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+
+ await selectNode("#testElement", inspector);
+ await testRuleView(view, inspector.selection.nodeFront);
+});
+
+async function testRuleView(ruleView, nodeFront) {
+ info("Testing font-family tooltips in the rule view");
+
+ const tooltip = ruleView.tooltips.getTooltip("previewTooltip");
+ const panel = tooltip.panel;
+
+ // Check that the rule view has a tooltip and that a XUL panel has
+ // been created
+ ok(tooltip, "Tooltip instance exists");
+ ok(panel, "XUL panel exists");
+
+ // Get the computed font family property inside the font rule view
+ const propertyList = ruleView.element.querySelectorAll(
+ ".ruleview-propertylist"
+ );
+ const fontExpander =
+ propertyList[1].querySelectorAll(".ruleview-expander")[0];
+ fontExpander.click();
+
+ const rule = getRuleViewRule(ruleView, "#testElement");
+ const computedlist = rule.querySelectorAll(".ruleview-computed");
+ let valueSpan;
+ for (const computed of computedlist) {
+ const propertyName = computed.querySelector(".ruleview-propertyname");
+ if (propertyName.textContent == "font-family") {
+ valueSpan = computed.querySelector(".ruleview-propertyvalue");
+ break;
+ }
+ }
+
+ // And verify that the tooltip gets shown on this property
+ const previewTooltip = await assertShowPreviewTooltip(ruleView, valueSpan);
+
+ const images = panel.getElementsByTagName("img");
+ is(images.length, 1, "Tooltip contains an image");
+ ok(
+ images[0].getAttribute("src").startsWith("data:"),
+ "Tooltip contains a data-uri image as expected"
+ );
+
+ const dataURL = await getFontFamilyDataURL(valueSpan.textContent, nodeFront);
+ is(
+ images[0].getAttribute("src"),
+ dataURL,
+ "Tooltip contains the correct data-uri image"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, valueSpan);
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-size.js b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-size.js
new file mode 100644
index 0000000000..4458477a0c
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_tooltip-size.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Checking tooltips dimensions, to make sure their big enough to display their
+// content
+
+const TEST_URI = `
+ <style type="text/css">
+ div {
+ width: 300px;height: 300px;border-radius: 50%;
+ background: red url(chrome://global/skin/icons/help.svg);
+ }
+ </style>
+ <div></div>
+`;
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+ await selectNode("div", inspector);
+ await testImageDimension(view);
+ await testPickerDimension(view);
+});
+
+async function testImageDimension(ruleView) {
+ info("Testing background-image tooltip dimensions");
+
+ const tooltip = ruleView.tooltips.getTooltip("previewTooltip");
+ const panel = tooltip.panel;
+ const { valueSpan } = getRuleViewProperty(ruleView, "div", "background");
+ const uriSpan = valueSpan.querySelector(".theme-link");
+
+ // Make sure there is a hover tooltip for this property, this also will fill
+ // the tooltip with its content
+ const previewTooltip = await assertShowPreviewTooltip(ruleView, uriSpan);
+
+ // Let's not test for a specific size, but instead let's make sure it's at
+ // least as big as the image
+ const imageRect = panel.querySelector("img").getBoundingClientRect();
+ const panelRect = panel.getBoundingClientRect();
+
+ Assert.greaterOrEqual(
+ panelRect.width,
+ imageRect.width,
+ "The panel is wide enough to show the image"
+ );
+ Assert.greaterOrEqual(
+ panelRect.height,
+ imageRect.height,
+ "The panel is high enough to show the image"
+ );
+
+ await assertTooltipHiddenOnMouseOut(previewTooltip, uriSpan);
+}
+
+async function testPickerDimension(ruleView) {
+ info("Testing color-picker tooltip dimensions");
+
+ const { valueSpan } = getRuleViewProperty(ruleView, "div", "background");
+ const swatch = valueSpan.querySelector(".ruleview-colorswatch");
+ const cPicker = ruleView.tooltips.getTooltip("colorPicker");
+
+ const onReady = cPicker.once("ready");
+ swatch.click();
+ await onReady;
+
+ // The colorpicker spectrum's iframe has a fixed width height, so let's
+ // make sure the tooltip is at least as big as that
+ const spectrumRect = cPicker.spectrum.element.getBoundingClientRect();
+ const panelRect = cPicker.tooltip.container.getBoundingClientRect();
+
+ Assert.greaterOrEqual(
+ panelRect.width,
+ spectrumRect.width,
+ "The panel is wide enough to show the picker"
+ );
+ Assert.greaterOrEqual(
+ panelRect.height,
+ spectrumRect.height,
+ "The panel is high enough to show the picker"
+ );
+
+ const onHidden = cPicker.tooltip.once("hidden");
+ const onRuleViewChanged = ruleView.once("ruleview-changed");
+ cPicker.hide();
+ await onHidden;
+ await onRuleViewChanged;
+}
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-01.js b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-01.js
new file mode 100644
index 0000000000..b4297a66c2
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-01.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the css transform highlighter is created only when asked and only one
+// instance exists across the inspector
+
+const TEST_URI = `
+ <style type="text/css">
+ body {
+ transform: skew(16deg);
+ }
+ </style>
+ Test the css transform highlighter
+`;
+
+const TYPE = "CssTransformHighlighter";
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+
+ let overlay = view.highlighters;
+
+ ok(!overlay.highlighters[TYPE], "No highlighter exists in the rule-view");
+ const h = await overlay._getHighlighter(TYPE);
+ ok(
+ overlay.highlighters[TYPE],
+ "The highlighter has been created in the rule-view"
+ );
+ is(h, overlay.highlighters[TYPE], "The right highlighter has been created");
+ const h2 = await overlay._getHighlighter(TYPE);
+ is(
+ h,
+ h2,
+ "The same instance of highlighter is returned everytime in the rule-view"
+ );
+
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ const cView = selectComputedView(inspector);
+ await onComputedViewReady;
+ overlay = cView.highlighters;
+
+ ok(overlay.highlighters[TYPE], "The highlighter exists in the computed-view");
+ const h3 = await overlay._getHighlighter(TYPE);
+ is(
+ h,
+ h3,
+ "The same instance of highlighter is returned everytime " +
+ "in the computed-view"
+ );
+});
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-02.js b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-02.js
new file mode 100644
index 0000000000..f1ddc68892
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-02.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the css transform highlighter is created when hovering over a
+// transform property
+
+const TEST_URI = `
+ <style type="text/css">
+ body {
+ transform: skew(16deg);
+ color: yellow;
+ }
+ </style>
+ Test the css transform highlighter
+`;
+
+var TYPE = "CssTransformHighlighter";
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+ let hs = view.highlighters;
+
+ ok(!hs.highlighters[TYPE], "No highlighter exists in the rule-view (1)");
+
+ info("Faking a mousemove on a non-transform property");
+ let { valueSpan } = getRuleViewProperty(view, "body", "color");
+ hs.onMouseMove({ target: valueSpan });
+ ok(!hs.highlighters[TYPE], "No highlighter exists in the rule-view (2)");
+
+ info("Faking a mousemove on a transform property");
+ ({ valueSpan } = getRuleViewProperty(view, "body", "transform"));
+ let onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+
+ const onComputedViewReady = inspector.once("computed-view-refreshed");
+ const cView = selectComputedView(inspector);
+ await onComputedViewReady;
+ hs = cView.highlighters;
+
+ info("Remove the created transform highlighter");
+ hs.highlighters[TYPE].finalize();
+ hs.highlighters[TYPE] = null;
+
+ info("Faking a mousemove on a non-transform property");
+ ({ valueSpan } = getComputedViewProperty(cView, "color"));
+ hs.onMouseMove({ target: valueSpan });
+ ok(!hs.highlighters[TYPE], "No highlighter exists in the computed-view (3)");
+
+ info("Faking a mousemove on a transform property");
+ ({ valueSpan } = getComputedViewProperty(cView, "transform"));
+ onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+
+ ok(
+ hs.highlighters[TYPE],
+ "The highlighter has been created in the computed-view"
+ );
+});
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-03.js b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-03.js
new file mode 100644
index 0000000000..ee95db2432
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-03.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the css transform highlighter is shown when hovering over transform
+// properties
+
+// Note that in this test, we mock the highlighter front, merely testing the
+// behavior of the style-inspector UI for now
+
+const TEST_URI = `
+ <style type="text/css">
+ html {
+ transform: scale(.9);
+ }
+ body {
+ transform: skew(16deg);
+ color: purple;
+ }
+ </style>
+ Test the css transform highlighter
+`;
+
+const TYPE = "CssTransformHighlighter";
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+
+ // Mock the highlighter front to get the reference of the NodeFront
+ const HighlighterFront = {
+ isShown: false,
+ nodeFront: null,
+ nbOfTimesShown: 0,
+ show(nodeFront) {
+ this.nodeFront = nodeFront;
+ this.isShown = true;
+ this.nbOfTimesShown++;
+ return Promise.resolve(true);
+ },
+ hide() {
+ this.nodeFront = null;
+ this.isShown = false;
+ return Promise.resolve();
+ },
+ finalize() {},
+ };
+
+ // Inject the mock highlighter in the rule-view
+ const hs = view.highlighters;
+ hs.highlighters[TYPE] = HighlighterFront;
+
+ let { valueSpan } = getRuleViewProperty(view, "body", "transform");
+
+ info("Checking that the HighlighterFront's show/hide methods are called");
+ let onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+ ok(HighlighterFront.isShown, "The highlighter is shown");
+ let onHighlighterHidden = hs.once("css-transform-highlighter-hidden");
+ hs.onMouseOut();
+ await onHighlighterHidden;
+ ok(!HighlighterFront.isShown, "The highlighter is hidden");
+
+ info(
+ "Checking that hovering several times over the same property doesn't" +
+ " show the highlighter several times"
+ );
+ const nb = HighlighterFront.nbOfTimesShown;
+ onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+ is(HighlighterFront.nbOfTimesShown, nb + 1, "The highlighter was shown once");
+ hs.onMouseMove({ target: valueSpan });
+ hs.onMouseMove({ target: valueSpan });
+ is(
+ HighlighterFront.nbOfTimesShown,
+ nb + 1,
+ "The highlighter was shown once, after several mousemove"
+ );
+
+ info("Checking that the right NodeFront reference is passed");
+ await selectNode("html", inspector);
+ ({ valueSpan } = getRuleViewProperty(view, "html", "transform"));
+ onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+ is(
+ HighlighterFront.nodeFront.tagName,
+ "HTML",
+ "The right NodeFront is passed to the highlighter (1)"
+ );
+
+ await selectNode("body", inspector);
+ ({ valueSpan } = getRuleViewProperty(view, "body", "transform"));
+ onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+ is(
+ HighlighterFront.nodeFront.tagName,
+ "BODY",
+ "The right NodeFront is passed to the highlighter (2)"
+ );
+
+ info(
+ "Checking that the highlighter gets hidden when hovering a " +
+ "non-transform property"
+ );
+ ({ valueSpan } = getRuleViewProperty(view, "body", "color"));
+ onHighlighterHidden = hs.once("css-transform-highlighter-hidden");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterHidden;
+ ok(!HighlighterFront.isShown, "The highlighter is hidden");
+});
diff --git a/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-04.js b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-04.js
new file mode 100644
index 0000000000..1bf76d517b
--- /dev/null
+++ b/devtools/client/inspector/shared/test/browser_styleinspector_transform-highlighter-04.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that the css transform highlighter is shown only when hovering over a
+// transform declaration that isn't overriden or disabled
+
+// Note that unlike the other browser_styleinspector_transform-highlighter-N.js
+// tests, this one only tests the rule-view as only this view features disabled
+// and overriden properties
+
+const TEST_URI = `
+ <style type="text/css">
+ div {
+ background: purple;
+ width:300px;height:300px;
+ transform: rotate(16deg);
+ }
+ .test {
+ transform: skew(25deg);
+ }
+ </style>
+ <div class="test"></div>
+`;
+
+const TYPE = "CssTransformHighlighter";
+
+add_task(async function () {
+ await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI));
+ const { inspector, view } = await openRuleView();
+ await selectNode(".test", inspector);
+
+ const hs = view.highlighters;
+
+ info("Faking a mousemove on the overriden property");
+ let { valueSpan } = getRuleViewProperty(view, "div", "transform");
+ hs.onMouseMove({ target: valueSpan });
+ ok(
+ !hs.highlighters[TYPE],
+ "No highlighter was created for the overriden property"
+ );
+
+ info("Disabling the applied property");
+ const classRuleEditor = getRuleViewRuleEditor(view, 1);
+ const propEditor = classRuleEditor.rule.textProps[0].editor;
+ propEditor.enable.click();
+ await classRuleEditor.rule._applyingModifications;
+
+ info("Faking a mousemove on the disabled property");
+ ({ valueSpan } = getRuleViewProperty(view, ".test", "transform"));
+ hs.onMouseMove({ target: valueSpan });
+ ok(
+ !hs.highlighters[TYPE],
+ "No highlighter was created for the disabled property"
+ );
+
+ info("Faking a mousemove on the now unoverriden property");
+ ({ valueSpan } = getRuleViewProperty(view, "div", "transform"));
+ const onHighlighterShown = hs.once("css-transform-highlighter-shown");
+ hs.onMouseMove({ target: valueSpan });
+ await onHighlighterShown;
+});
diff --git a/devtools/client/inspector/shared/test/doc_content_style_changes.html b/devtools/client/inspector/shared/test/doc_content_style_changes.html
new file mode 100644
index 0000000000..c439f2bf4f
--- /dev/null
+++ b/devtools/client/inspector/shared/test/doc_content_style_changes.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<style>
+#test {
+ color: red;
+}
+/* Adding/removing the green-class on #test should refresh the rule-view when #test is
+ selected */
+#test.green-class {
+ color: green;
+}
+/* Adding/removing the purple-class on #parent should refresh the rule-view when #test is
+ selected */
+#parent.purple-class #test {
+ color: purple;
+}
+/* Adding/removing the blue-class on #sibling should refresh the rule-view when #test is
+ selected*/
+#sibling.blue-class + #test {
+ color: blue;
+}
+</style>
+<div id="parent">
+ <div>
+ <div id="sibling"></div>
+ <div id="test">test</div>
+ </div>
+</div>
diff --git a/devtools/client/inspector/shared/test/head.js b/devtools/client/inspector/shared/test/head.js
new file mode 100644
index 0000000000..f87fed0f03
--- /dev/null
+++ b/devtools/client/inspector/shared/test/head.js
@@ -0,0 +1,218 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint no-unused-vars: [2, {"vars": "local"}] */
+
+"use strict";
+
+// Import the inspector's head.js first (which itself imports shared-head.js).
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/devtools/client/inspector/test/head.js",
+ this
+);
+
+var {
+ CssRuleView,
+} = require("resource://devtools/client/inspector/rules/rules.js");
+var {
+ getInplaceEditorForSpan: inplaceEditor,
+} = require("resource://devtools/client/shared/inplace-editor.js");
+const {
+ getColor: getThemeColor,
+} = require("resource://devtools/client/shared/theme.js");
+
+const TEST_URL_ROOT =
+ "http://example.com/browser/devtools/client/inspector/shared/test/";
+const TEST_URL_ROOT_SSL =
+ "https://example.com/browser/devtools/client/inspector/shared/test/";
+const ROOT_TEST_DIR = getRootDirectory(gTestPath);
+const STYLE_INSPECTOR_L10N = new LocalizationHelper(
+ "devtools/shared/locales/styleinspector.properties"
+);
+
+// Clean-up all prefs that might have been changed during a test run
+// (safer here because if the test fails, then the pref is never reverted)
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("devtools.defaultColorUnit");
+});
+
+/**
+ * The functions found below are here to ease test development and maintenance.
+ * Most of these functions are stateless and will require some form of context
+ * (the instance of the current toolbox, or inspector panel for instance).
+ *
+ * Most of these functions are async too and return promises.
+ *
+ * All tests should follow the following pattern:
+ *
+ * add_task(async function() {
+ * await addTab(TEST_URI);
+ * let {toolbox, inspector} = await openInspector();
+ * await inspector.sidebar.select(viewId);
+ * let view = inspector.getPanel(viewId).view;
+ * await selectNode("#test", inspector);
+ * await someAsyncTestFunction(view);
+ * });
+ *
+ * add_task is the way to define the testcase in the test file. It accepts
+ * a single argument: a function returning a promise (usually async function).
+ *
+ * There is no need to clean tabs up at the end of a test as this is done
+ * automatically.
+ *
+ * It is advised not to store any references on the global scope. There
+ * shouldn't be a need to anyway. Thanks to async functions, test steps, even
+ * though asynchronous, can be described in a nice flat way, and
+ * if/for/while/... control flow can be used as in sync code, making it
+ * possible to write the outline of the test case all in add_task, and delegate
+ * actual processing and assertions to other functions.
+ */
+
+/* *********************************************
+ * UTILS
+ * *********************************************
+ * General test utilities.
+ * Add new tabs, open the toolbox and switch to the various panels, select
+ * nodes, get node references, ...
+ */
+
+/**
+ * Polls a given function waiting for it to return true.
+ *
+ * @param {Function} validatorFn
+ * A validator function that returns a boolean.
+ * This is called every few milliseconds to check if the result is true.
+ * When it is true, the promise resolves.
+ * @param {String} name
+ * Optional name of the test. This is used to generate
+ * the success and failure messages.
+ * @return a promise that resolves when the function returned true or rejects
+ * if the timeout is reached
+ */
+function waitForSuccess(validatorFn, name = "untitled") {
+ return new Promise(resolve => {
+ function wait(validator) {
+ if (validator()) {
+ ok(true, "Validator function " + name + " returned true");
+ resolve();
+ } else {
+ setTimeout(() => wait(validator), 200);
+ }
+ }
+ wait(validatorFn);
+ });
+}
+
+/**
+ * Get the dataURL for the font family tooltip.
+ *
+ * @param {String} font
+ * The font family value.
+ * @param {object} nodeFront
+ * The NodeActor that will used to retrieve the dataURL for the
+ * font family tooltip contents.
+ */
+var getFontFamilyDataURL = async function (font, nodeFront) {
+ const fillStyle = getThemeColor("body-color");
+
+ const { data } = await nodeFront.getFontFamilyDataURL(font, fillStyle);
+ const dataURL = await data.string();
+ return dataURL;
+};
+
+/* *********************************************
+ * RULE-VIEW
+ * *********************************************
+ * Rule-view related test utility functions
+ * This object contains functions to get rules, get properties, ...
+ */
+
+/**
+ * Simulate a color change in a given color picker tooltip, and optionally wait
+ * for a given element in the page to have its style changed as a result
+ *
+ * @param {RuleView} ruleView
+ * The related rule view instance
+ * @param {SwatchColorPickerTooltip} colorPicker
+ * @param {Array} newRgba
+ * The new color to be set [r, g, b, a]
+ * @param {Object} expectedChange
+ * Optional object that needs the following props:
+ * - {DOMNode} element The element in the page that will have its
+ * style changed.
+ * - {String} name The style name that will be changed
+ * - {String} value The expected style value
+ * The style will be checked like so: getComputedStyle(element)[name] === value
+ */
+var simulateColorPickerChange = async function (
+ ruleView,
+ colorPicker,
+ newRgba,
+ expectedChange
+) {
+ const onRuleViewChanged = ruleView.once("ruleview-changed");
+ info("Getting the spectrum colorpicker object");
+ const spectrum = await colorPicker.spectrum;
+ info("Setting the new color");
+ spectrum.rgb = newRgba;
+ info("Applying the change");
+ spectrum.updateUI();
+ spectrum.onChange();
+ info("Waiting for rule-view to update");
+ await onRuleViewChanged;
+
+ if (expectedChange) {
+ info("Waiting for the style to be applied on the page");
+ await waitForSuccess(() => {
+ const { element, name, value } = expectedChange;
+ return content.getComputedStyle(element)[name] === value;
+ }, "Color picker change applied on the page");
+ }
+};
+
+/* *********************************************
+ * COMPUTED-VIEW
+ * *********************************************
+ * Computed-view related utility functions.
+ * Allows to get properties, links, expand properties, ...
+ */
+
+/**
+ * Get references to the name and value span nodes corresponding to a given
+ * property name in the computed-view
+ *
+ * @param {CssComputedView} view
+ * The instance of the computed view panel
+ * @param {String} name
+ * The name of the property to retrieve
+ * @return an object {nameSpan, valueSpan}
+ */
+function getComputedViewProperty(view, name) {
+ let prop;
+ for (const property of view.styleDocument.querySelectorAll(
+ ".computed-property-view"
+ )) {
+ const nameSpan = property.querySelector(".computed-property-name");
+ const valueSpan = property.querySelector(".computed-property-value");
+
+ if (nameSpan.firstChild.textContent === name) {
+ prop = { nameSpan, valueSpan };
+ break;
+ }
+ }
+ return prop;
+}
+
+/**
+ * Get the text value of the property corresponding to a given name in the
+ * computed-view
+ *
+ * @param {CssComputedView} view
+ * The instance of the computed view panel
+ * @param {String} name
+ * The name of the property to retrieve
+ * @return {String} The property value
+ */
+function getComputedViewPropertyValue(view, name, propertyName) {
+ return getComputedViewProperty(view, name, propertyName).valueSpan
+ .textContent;
+}
diff --git a/devtools/client/inspector/shared/tooltips-overlay.js b/devtools/client/inspector/shared/tooltips-overlay.js
new file mode 100644
index 0000000000..7c2d67c2da
--- /dev/null
+++ b/devtools/client/inspector/shared/tooltips-overlay.js
@@ -0,0 +1,570 @@
+/* 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";
+
+/**
+ * The tooltip overlays are tooltips that appear when hovering over property values and
+ * editor tooltips that appear when clicking swatch based editors.
+ */
+
+const flags = require("resource://devtools/shared/flags.js");
+const {
+ VIEW_NODE_CSS_QUERY_CONTAINER,
+ VIEW_NODE_CSS_SELECTOR_WARNINGS,
+ VIEW_NODE_FONT_TYPE,
+ VIEW_NODE_IMAGE_URL_TYPE,
+ VIEW_NODE_INACTIVE_CSS,
+ VIEW_NODE_VALUE_TYPE,
+ VIEW_NODE_VARIABLE_TYPE,
+} = require("resource://devtools/client/inspector/shared/node-types.js");
+
+loader.lazyRequireGetter(
+ this,
+ "getColor",
+ "resource://devtools/client/shared/theme.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "HTMLTooltip",
+ "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ ["getImageDimensions", "setImageTooltip", "setBrokenImageTooltip"],
+ "resource://devtools/client/shared/widgets/tooltip/ImageTooltipHelper.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "setVariableTooltip",
+ "resource://devtools/client/shared/widgets/tooltip/VariableTooltipHelper.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "InactiveCssTooltipHelper",
+ "resource://devtools/client/shared/widgets/tooltip/inactive-css-tooltip-helper.js",
+ false
+);
+loader.lazyRequireGetter(
+ this,
+ "CssCompatibilityTooltipHelper",
+ "resource://devtools/client/shared/widgets/tooltip/css-compatibility-tooltip-helper.js",
+ false
+);
+loader.lazyRequireGetter(
+ this,
+ "CssQueryContainerTooltipHelper",
+ "resource://devtools/client/shared/widgets/tooltip/css-query-container-tooltip-helper.js",
+ false
+);
+loader.lazyRequireGetter(
+ this,
+ "CssSelectorWarningsTooltipHelper",
+ "resource://devtools/client/shared/widgets/tooltip/css-selector-warnings-tooltip-helper.js",
+ false
+);
+
+const PREF_IMAGE_TOOLTIP_SIZE = "devtools.inspector.imagePreviewTooltipSize";
+
+// Types of existing tooltips
+const TOOLTIP_CSS_COMPATIBILITY = "css-compatibility";
+const TOOLTIP_CSS_QUERY_CONTAINER = "css-query-info";
+const TOOLTIP_CSS_SELECTOR_WARNINGS = "css-selector-warnings";
+const TOOLTIP_FONTFAMILY_TYPE = "font-family";
+const TOOLTIP_IMAGE_TYPE = "image";
+const TOOLTIP_INACTIVE_CSS = "inactive-css";
+const TOOLTIP_VARIABLE_TYPE = "variable";
+
+// Telemetry
+const TOOLTIP_SHOWN_SCALAR = "devtools.tooltip.shown";
+
+/**
+ * Manages all tooltips in the style-inspector.
+ *
+ * @param {CssRuleView|CssComputedView} view
+ * Either the rule-view or computed-view panel
+ */
+function TooltipsOverlay(view) {
+ this.view = view;
+ this._instances = new Map();
+
+ this._onNewSelection = this._onNewSelection.bind(this);
+ this.view.inspector.selection.on("new-node-front", this._onNewSelection);
+
+ this.addToView();
+}
+
+TooltipsOverlay.prototype = {
+ get isEditing() {
+ for (const [, tooltip] of this._instances) {
+ if (typeof tooltip.isEditing == "function" && tooltip.isEditing()) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Add the tooltips overlay to the view. This will start tracking mouse
+ * movements and display tooltips when needed
+ */
+ addToView() {
+ if (this._isStarted || this._isDestroyed) {
+ return;
+ }
+
+ this._isStarted = true;
+
+ this.inactiveCssTooltipHelper = new InactiveCssTooltipHelper();
+ this.compatibilityTooltipHelper = new CssCompatibilityTooltipHelper();
+ this.cssQueryContainerTooltipHelper = new CssQueryContainerTooltipHelper();
+ this.cssSelectorWarningsTooltipHelper =
+ new CssSelectorWarningsTooltipHelper();
+
+ // Instantiate the interactiveTooltip and preview tooltip when the
+ // rule/computed view is hovered over in order to call
+ // `tooltip.startTogglingOnHover`. This will allow the tooltip to be shown
+ // when an appropriate element is hovered over.
+ for (const type of ["interactiveTooltip", "previewTooltip"]) {
+ if (flags.testing) {
+ this.getTooltip(type);
+ } else {
+ // Lazily get the preview tooltip to avoid loading HTMLTooltip.
+ this.view.element.addEventListener(
+ "mousemove",
+ () => {
+ this.getTooltip(type);
+ },
+ { once: true }
+ );
+ }
+ }
+ },
+
+ /**
+ * Lazily fetch and initialize the different tooltips that are used in the inspector.
+ * These tooltips are attached to the toolbox document if they require a popup panel.
+ * Otherwise, it is attached to the inspector panel document if it is an inline editor.
+ *
+ * @param {String} name
+ * Identifier name for the tooltip
+ */
+ getTooltip(name) {
+ let tooltip = this._instances.get(name);
+ if (tooltip) {
+ return tooltip;
+ }
+ const { doc } = this.view.inspector.toolbox;
+ switch (name) {
+ case "colorPicker":
+ const SwatchColorPickerTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchColorPickerTooltip.js");
+ tooltip = new SwatchColorPickerTooltip(doc, this.view.inspector);
+ break;
+ case "cubicBezier":
+ const SwatchCubicBezierTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchCubicBezierTooltip.js");
+ tooltip = new SwatchCubicBezierTooltip(doc);
+ break;
+ case "linearEaseFunction":
+ const SwatchLinearEasingFunctionTooltip = require("devtools/client/shared/widgets/tooltip/SwatchLinearEasingFunctionTooltip");
+ tooltip = new SwatchLinearEasingFunctionTooltip(doc);
+ break;
+ case "filterEditor":
+ const SwatchFilterTooltip = require("resource://devtools/client/shared/widgets/tooltip/SwatchFilterTooltip.js");
+ tooltip = new SwatchFilterTooltip(doc);
+ break;
+ case "interactiveTooltip":
+ tooltip = new HTMLTooltip(doc, {
+ type: "doorhanger",
+ useXulWrapper: true,
+ noAutoHide: true,
+ });
+ tooltip.startTogglingOnHover(
+ this.view.element,
+ this.onInteractiveTooltipTargetHover.bind(this),
+ {
+ interactive: true,
+ }
+ );
+ break;
+ case "previewTooltip":
+ tooltip = new HTMLTooltip(doc, {
+ type: "arrow",
+ useXulWrapper: true,
+ });
+ tooltip.startTogglingOnHover(
+ this.view.element,
+ this._onPreviewTooltipTargetHover.bind(this)
+ );
+ break;
+ default:
+ throw new Error(`Unsupported tooltip '${name}'`);
+ }
+ this._instances.set(name, tooltip);
+ return tooltip;
+ },
+
+ /**
+ * Remove the tooltips overlay from the view. This will stop tracking mouse
+ * movements and displaying tooltips
+ */
+ removeFromView() {
+ if (!this._isStarted || this._isDestroyed) {
+ return;
+ }
+
+ for (const [, tooltip] of this._instances) {
+ tooltip.destroy();
+ }
+
+ this.inactiveCssTooltipHelper.destroy();
+ this.compatibilityTooltipHelper.destroy();
+
+ this._isStarted = false;
+ },
+
+ /**
+ * Given a hovered node info, find out which type of tooltip should be shown,
+ * if any
+ *
+ * @param {Object} nodeInfo
+ * @return {String} The tooltip type to be shown, or null
+ */
+ _getTooltipType({ type, value: prop }) {
+ let tooltipType = null;
+
+ // Image preview tooltip
+ if (type === VIEW_NODE_IMAGE_URL_TYPE) {
+ tooltipType = TOOLTIP_IMAGE_TYPE;
+ }
+
+ // Font preview tooltip
+ if (
+ (type === VIEW_NODE_VALUE_TYPE && prop.property === "font-family") ||
+ type === VIEW_NODE_FONT_TYPE
+ ) {
+ const value = prop.value.toLowerCase();
+ if (value !== "inherit" && value !== "unset" && value !== "initial") {
+ tooltipType = TOOLTIP_FONTFAMILY_TYPE;
+ }
+ }
+
+ // Inactive CSS tooltip
+ if (type === VIEW_NODE_INACTIVE_CSS) {
+ tooltipType = TOOLTIP_INACTIVE_CSS;
+ }
+
+ // Variable preview tooltip
+ if (type === VIEW_NODE_VARIABLE_TYPE) {
+ tooltipType = TOOLTIP_VARIABLE_TYPE;
+ }
+
+ // Container info tooltip
+ if (type === VIEW_NODE_CSS_QUERY_CONTAINER) {
+ tooltipType = TOOLTIP_CSS_QUERY_CONTAINER;
+ }
+
+ // Selector warnings info tooltip
+ if (type === VIEW_NODE_CSS_SELECTOR_WARNINGS) {
+ tooltipType = TOOLTIP_CSS_SELECTOR_WARNINGS;
+ }
+
+ return tooltipType;
+ },
+
+ _removePreviousInstances() {
+ for (const tooltip of this._instances.values()) {
+ if (tooltip.isVisible()) {
+ if (tooltip.revert) {
+ tooltip.revert();
+ }
+ tooltip.hide();
+ }
+ }
+ },
+
+ /**
+ * Executed by the tooltip when the pointer hovers over an element of the
+ * view. Used to decide whether the tooltip should be shown or not and to
+ * actually put content in it.
+ * Checks if the hovered target is a css value we support tooltips for.
+ *
+ * @param {DOMNode} target The currently hovered node
+ * @return {Promise}
+ */
+ async _onPreviewTooltipTargetHover(target) {
+ const nodeInfo = this.view.getNodeInfo(target);
+ if (!nodeInfo) {
+ // The hovered node isn't something we care about
+ return false;
+ }
+
+ const type = this._getTooltipType(nodeInfo);
+ if (!type) {
+ // There is no tooltip type defined for the hovered node
+ return false;
+ }
+
+ this._removePreviousInstances();
+
+ const inspector = this.view.inspector;
+
+ if (type === TOOLTIP_IMAGE_TYPE) {
+ try {
+ await this._setImagePreviewTooltip(nodeInfo.value.url);
+ } catch (e) {
+ await setBrokenImageTooltip(
+ this.getTooltip("previewTooltip"),
+ this.view.inspector.panelDoc
+ );
+ }
+
+ this.sendOpenScalarToTelemetry(type);
+
+ return true;
+ }
+
+ if (type === TOOLTIP_FONTFAMILY_TYPE) {
+ const font = nodeInfo.value.value;
+ const nodeFront = inspector.selection.nodeFront;
+ await this._setFontPreviewTooltip(font, nodeFront);
+
+ this.sendOpenScalarToTelemetry(type);
+
+ if (nodeInfo.type === VIEW_NODE_FONT_TYPE) {
+ // If the hovered element is on the font family span, anchor
+ // the tooltip on the whole property value instead.
+ return target.parentNode;
+ }
+ return true;
+ }
+
+ if (
+ type === TOOLTIP_VARIABLE_TYPE &&
+ nodeInfo.value.value.startsWith("--")
+ ) {
+ const variable = nodeInfo.value.variable;
+ await this._setVariablePreviewTooltip(variable);
+
+ this.sendOpenScalarToTelemetry(type);
+
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Executed by the tooltip when the pointer hovers over an element of the
+ * view. Used to decide whether the tooltip should be shown or not and to
+ * actually put content in it.
+ * Checks if the hovered target is a css value we support tooltips for.
+ *
+ * @param {DOMNode} target
+ * The currently hovered node
+ * @return {Boolean}
+ * true if shown, false otherwise.
+ */
+ async onInteractiveTooltipTargetHover(target) {
+ if (target.classList.contains("ruleview-compatibility-warning")) {
+ const nodeCompatibilityInfo = await this.view.getNodeCompatibilityInfo(
+ target
+ );
+
+ await this.compatibilityTooltipHelper.setContent(
+ nodeCompatibilityInfo,
+ this.getTooltip("interactiveTooltip")
+ );
+
+ this.sendOpenScalarToTelemetry(TOOLTIP_CSS_COMPATIBILITY);
+ return true;
+ }
+
+ const nodeInfo = this.view.getNodeInfo(target);
+ if (!nodeInfo) {
+ // The hovered node isn't something we care about.
+ return false;
+ }
+
+ const type = this._getTooltipType(nodeInfo);
+ if (!type) {
+ // There is no tooltip type defined for the hovered node.
+ return false;
+ }
+
+ this._removePreviousInstances();
+
+ if (type === TOOLTIP_INACTIVE_CSS) {
+ // Ensure this is the correct node and not a parent.
+ if (!target.classList.contains("ruleview-unused-warning")) {
+ return false;
+ }
+
+ await this.inactiveCssTooltipHelper.setContent(
+ nodeInfo.value,
+ this.getTooltip("interactiveTooltip")
+ );
+
+ this.sendOpenScalarToTelemetry(type);
+
+ return true;
+ }
+
+ if (type === TOOLTIP_CSS_QUERY_CONTAINER) {
+ // Ensure this is the correct node and not a parent.
+ if (!target.closest(".container-query .container-query-declaration")) {
+ return false;
+ }
+
+ await this.cssQueryContainerTooltipHelper.setContent(
+ nodeInfo.value,
+ this.getTooltip("interactiveTooltip")
+ );
+
+ this.sendOpenScalarToTelemetry(type);
+
+ return true;
+ }
+
+ if (type === TOOLTIP_CSS_SELECTOR_WARNINGS) {
+ await this.cssSelectorWarningsTooltipHelper.setContent(
+ nodeInfo.value,
+ this.getTooltip("interactiveTooltip")
+ );
+
+ this.sendOpenScalarToTelemetry(type);
+
+ return true;
+ }
+
+ return false;
+ },
+
+ /**
+ * Send a telemetry Scalar showing that a tooltip of `type` has been opened.
+ *
+ * @param {String} type
+ * The node type from `devtools/client/inspector/shared/node-types` or the Tooltip type.
+ */
+ sendOpenScalarToTelemetry(type) {
+ this.view.inspector.telemetry.keyedScalarAdd(TOOLTIP_SHOWN_SCALAR, type, 1);
+ },
+
+ /**
+ * Set the content of the preview tooltip to display an image preview. The image URL can
+ * be relative, a call will be made to the debuggee to retrieve the image content as an
+ * imageData URI.
+ *
+ * @param {String} imageUrl
+ * The image url value (may be relative or absolute).
+ * @return {Promise} A promise that resolves when the preview tooltip content is ready
+ */
+ async _setImagePreviewTooltip(imageUrl) {
+ const doc = this.view.inspector.panelDoc;
+ const maxDim = Services.prefs.getIntPref(PREF_IMAGE_TOOLTIP_SIZE);
+
+ let naturalWidth, naturalHeight;
+ if (imageUrl.startsWith("data:")) {
+ // If the imageUrl already is a data-url, save ourselves a round-trip
+ const size = await getImageDimensions(doc, imageUrl);
+ naturalWidth = size.naturalWidth;
+ naturalHeight = size.naturalHeight;
+ } else {
+ const inspectorFront = this.view.inspector.inspectorFront;
+ const { data, size } = await inspectorFront.getImageDataFromURL(
+ imageUrl,
+ maxDim
+ );
+ imageUrl = await data.string();
+ naturalWidth = size.naturalWidth;
+ naturalHeight = size.naturalHeight;
+ }
+
+ await setImageTooltip(this.getTooltip("previewTooltip"), doc, imageUrl, {
+ maxDim,
+ naturalWidth,
+ naturalHeight,
+ });
+ },
+
+ /**
+ * Set the content of the preview tooltip to display a font family preview.
+ *
+ * @param {String} font
+ * The font family value.
+ * @param {object} nodeFront
+ * The NodeActor that will used to retrieve the dataURL for the font
+ * family tooltip contents.
+ * @return {Promise} A promise that resolves when the preview tooltip content is ready
+ */
+ async _setFontPreviewTooltip(font, nodeFront) {
+ if (
+ !font ||
+ !nodeFront ||
+ typeof nodeFront.getFontFamilyDataURL !== "function"
+ ) {
+ throw new Error("Unable to create font preview tooltip content.");
+ }
+
+ font = font.replace(/"/g, "'");
+ font = font.replace("!important", "");
+ font = font.trim();
+
+ const fillStyle = getColor("body-color");
+ const { data, size: maxDim } = await nodeFront.getFontFamilyDataURL(
+ font,
+ fillStyle
+ );
+
+ const imageUrl = await data.string();
+ const doc = this.view.inspector.panelDoc;
+ const { naturalWidth, naturalHeight } = await getImageDimensions(
+ doc,
+ imageUrl
+ );
+
+ await setImageTooltip(this.getTooltip("previewTooltip"), doc, imageUrl, {
+ hideDimensionLabel: true,
+ hideCheckeredBackground: true,
+ maxDim,
+ naturalWidth,
+ naturalHeight,
+ });
+ },
+
+ /**
+ * Set the content of the preview tooltip to display a variable preview.
+ *
+ * @param {String} text
+ * The text to display for the variable tooltip
+ * @return {Promise} A promise that resolves when the preview tooltip content is ready
+ */
+ async _setVariablePreviewTooltip(text) {
+ const doc = this.view.inspector.panelDoc;
+ await setVariableTooltip(this.getTooltip("previewTooltip"), doc, text);
+ },
+
+ _onNewSelection() {
+ for (const [, tooltip] of this._instances) {
+ tooltip.hide();
+ }
+ },
+
+ /**
+ * Destroy this overlay instance, removing it from the view
+ */
+ destroy() {
+ this.removeFromView();
+
+ this.view.inspector.selection.off("new-node-front", this._onNewSelection);
+ this.view = null;
+
+ this._isDestroyed = true;
+ },
+};
+
+module.exports = TooltipsOverlay;
diff --git a/devtools/client/inspector/shared/utils.js b/devtools/client/inspector/shared/utils.js
new file mode 100644
index 0000000000..542f9897b1
--- /dev/null
+++ b/devtools/client/inspector/shared/utils.js
@@ -0,0 +1,239 @@
+/* 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";
+
+loader.lazyRequireGetter(
+ this,
+ "KeyCodes",
+ "resource://devtools/client/shared/keycodes.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "getCSSLexer",
+ "resource://devtools/shared/css/lexer.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "parseDeclarations",
+ "resource://devtools/shared/css/parsing-utils.js",
+ true
+);
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+
+/**
+ * Called when a character is typed in a value editor. This decides
+ * whether to advance or not, first by checking to see if ";" was
+ * typed, and then by lexing the input and seeing whether the ";"
+ * would be a terminator at this point.
+ *
+ * @param {number} keyCode
+ * Key code to be checked.
+ * @param {string} aValue
+ * Current text editor value.
+ * @param {number} insertionPoint
+ * The index of the insertion point.
+ * @return {Boolean} True if the focus should advance; false if
+ * the character should be inserted.
+ */
+function advanceValidate(keyCode, value, insertionPoint) {
+ // Only ";" has special handling here.
+ if (keyCode !== KeyCodes.DOM_VK_SEMICOLON) {
+ return false;
+ }
+
+ // Insert the character provisionally and see what happens. If we
+ // end up with a ";" symbol token, then the semicolon terminates the
+ // value. Otherwise it's been inserted in some spot where it has a
+ // valid meaning, like a comment or string.
+ value = value.slice(0, insertionPoint) + ";" + value.slice(insertionPoint);
+ const lexer = getCSSLexer(value);
+ while (true) {
+ const token = lexer.nextToken();
+ if (token.endOffset > insertionPoint) {
+ if (token.tokenType === "symbol" && token.text === ";") {
+ // The ";" is a terminator.
+ return true;
+ }
+ // The ";" is not a terminator in this context.
+ break;
+ }
+ }
+ return false;
+}
+
+/**
+ * Append a text node to an element.
+ *
+ * @param {Element} parent
+ * The parent node.
+ * @param {string} text
+ * The text content for the text node.
+ */
+function appendText(parent, text) {
+ parent.appendChild(parent.ownerDocument.createTextNode(text));
+}
+
+/**
+ * Event handler that causes a blur on the target if the input has
+ * multiple CSS properties as the value.
+ */
+function blurOnMultipleProperties(cssProperties) {
+ return e => {
+ setTimeout(() => {
+ const props = parseDeclarations(cssProperties.isKnown, e.target.value);
+ if (props.length > 1) {
+ e.target.blur();
+ }
+ }, 0);
+ };
+}
+
+/**
+ * Create a child element with a set of attributes.
+ *
+ * @param {Element} parent
+ * The parent node.
+ * @param {string} tagName
+ * The tag name.
+ * @param {object} attributes
+ * A set of attributes to set on the node.
+ */
+function createChild(parent, tagName, attributes = {}) {
+ const elt = parent.ownerDocument.createElementNS(HTML_NS, tagName);
+ for (const attr in attributes) {
+ if (attributes.hasOwnProperty(attr)) {
+ if (attr === "textContent") {
+ elt.textContent = attributes[attr];
+ } else if (attr === "child") {
+ elt.appendChild(attributes[attr]);
+ } else {
+ elt.setAttribute(attr, attributes[attr]);
+ }
+ }
+ }
+ parent.appendChild(elt);
+ return elt;
+}
+
+/**
+ * Retrieve the content of a longString (via a promise resolving a LongStringActor).
+ *
+ * @param {Promise} longStringActorPromise
+ * promise expected to resolve a LongStringActor instance
+ * @return {Promise} promise resolving with the retrieved string as argument
+ */
+async function getLongString(longStringActorPromise) {
+ try {
+ const longStringActor = await longStringActorPromise;
+ const string = await longStringActor.string();
+ longStringActor.release().catch(console.error);
+ return string;
+ } catch (e) {
+ console.error(e);
+ return undefined;
+ }
+}
+
+/**
+ * Returns a selector of the Element Rep from the grip. This is based on the
+ * getElements() function in our devtools-reps component for a ElementNode.
+ *
+ * @param {Object} grip
+ * Grip-like object that can be used with Reps.
+ * @return {String} selector of the element node.
+ */
+function getSelectorFromGrip(grip) {
+ const {
+ attributes,
+ nodeName,
+ isAfterPseudoElement,
+ isBeforePseudoElement,
+ isMarkerPseudoElement,
+ } = grip.preview;
+
+ if (isAfterPseudoElement) {
+ return "::after";
+ } else if (isBeforePseudoElement) {
+ return "::before";
+ } else if (isMarkerPseudoElement) {
+ return "::marker";
+ }
+
+ let selector = nodeName;
+
+ if (attributes.id) {
+ selector += `#${attributes.id}`;
+ }
+
+ if (attributes.class) {
+ selector += attributes.class
+ .trim()
+ .split(/\s+/)
+ .map(cls => `.${cls}`)
+ .join("");
+ }
+
+ return selector;
+}
+
+/**
+ * Log the provided error to the console and return a rejected Promise for
+ * this error.
+ *
+ * @param {Error} error
+ * The error to log
+ * @return {Promise} A rejected promise
+ */
+function promiseWarn(error) {
+ console.error(error);
+ return Promise.reject(error);
+}
+
+/**
+ * While waiting for a reps fix in https://github.com/firefox-devtools/reps/issues/92,
+ * translate nodeFront to a grip-like object that can be used with an ElementNode rep.
+ *
+ * @params {NodeFront} nodeFront
+ * The NodeFront for which we want to create a grip-like object.
+ * @returns {Object} a grip-like object that can be used with Reps.
+ */
+function translateNodeFrontToGrip(nodeFront) {
+ const { attributes } = nodeFront;
+
+ // The main difference between NodeFront and grips is that attributes are treated as
+ // a map in grips and as an array in NodeFronts.
+ const attributesMap = {};
+ for (const { name, value } of attributes) {
+ attributesMap[name] = value;
+ }
+
+ return {
+ actor: nodeFront.actorID,
+ preview: {
+ attributes: attributesMap,
+ attributesLength: attributes.length,
+ isAfterPseudoElement: nodeFront.isAfterPseudoElement,
+ isBeforePseudoElement: nodeFront.isBeforePseudoElement,
+ isMarkerPseudoElement: nodeFront.isMarkerPseudoElement,
+ // All the grid containers are assumed to be in the DOM tree.
+ isConnected: true,
+ // nodeName is already lowerCased in Node grips
+ nodeName: nodeFront.nodeName.toLowerCase(),
+ nodeType: nodeFront.nodeType,
+ },
+ };
+}
+
+exports.advanceValidate = advanceValidate;
+exports.appendText = appendText;
+exports.blurOnMultipleProperties = blurOnMultipleProperties;
+exports.createChild = createChild;
+exports.getLongString = getLongString;
+exports.getSelectorFromGrip = getSelectorFromGrip;
+exports.promiseWarn = promiseWarn;
+exports.translateNodeFrontToGrip = translateNodeFrontToGrip;
diff --git a/devtools/client/inspector/shared/walker-event-listener.js b/devtools/client/inspector/shared/walker-event-listener.js
new file mode 100644
index 0000000000..322c3be6df
--- /dev/null
+++ b/devtools/client/inspector/shared/walker-event-listener.js
@@ -0,0 +1,86 @@
+/* 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";
+
+/**
+ * WalkerEventListener provides a mechanism to listen the walker event of the inspector
+ * while reflecting the updating of TargetCommand.
+ */
+class WalkerEventListener {
+ /**
+ * @param {Inspector} - inspector
+ * @param {Object} - listenerMap
+ * The structure of listenerMap should be as follows.
+ * {
+ * walkerEventName1: eventHandler1,
+ * walkerEventName2: eventHandler2,
+ * ...
+ * }
+ */
+ constructor(inspector, listenerMap) {
+ this._inspector = inspector;
+ this._listenerMap = listenerMap;
+ this._onTargetAvailable = this._onTargetAvailable.bind(this);
+ this._onTargetDestroyed = this._onTargetDestroyed.bind(this);
+
+ this._init();
+ }
+
+ /**
+ * Clean up function.
+ */
+ destroy() {
+ this._inspector.commands.targetCommand.unwatchTargets({
+ types: [this._inspector.commands.targetCommand.TYPES.FRAME],
+ onAvailable: this._onTargetAvailable,
+ onDestroyed: this._onTargetDestroyed,
+ });
+
+ const targets = this._inspector.commands.targetCommand.getAllTargets([
+ this._inspector.commands.targetCommand.TYPES.FRAME,
+ ]);
+ for (const targetFront of targets) {
+ this._onTargetDestroyed({
+ targetFront,
+ });
+ }
+
+ this._inspector = null;
+ this._listenerMap = null;
+ }
+
+ _init() {
+ this._inspector.commands.targetCommand.watchTargets({
+ types: [this._inspector.commands.targetCommand.TYPES.FRAME],
+ onAvailable: this._onTargetAvailable,
+ onDestroyed: this._onTargetDestroyed,
+ });
+ }
+
+ async _onTargetAvailable({ targetFront }) {
+ const inspectorFront = await targetFront.getFront("inspector");
+ // In case of multiple fast navigations, the front may already be destroyed,
+ // in such scenario bail out and ignore this short lived target.
+ if (inspectorFront.isDestroyed() || !this._listenerMap) {
+ return;
+ }
+ const { walker } = inspectorFront;
+ for (const [name, listener] of Object.entries(this._listenerMap)) {
+ walker.on(name, listener);
+ }
+ }
+
+ _onTargetDestroyed({ targetFront }) {
+ const inspectorFront = targetFront.getCachedFront("inspector");
+ if (inspectorFront) {
+ const { walker } = inspectorFront;
+ for (const [name, listener] of Object.entries(this._listenerMap)) {
+ walker.off(name, listener);
+ }
+ }
+ }
+}
+
+module.exports = WalkerEventListener;