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