diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/client/inspector/grids/grid-inspector.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/client/inspector/grids/grid-inspector.js')
-rw-r--r-- | devtools/client/inspector/grids/grid-inspector.js | 776 |
1 files changed, 776 insertions, 0 deletions
diff --git a/devtools/client/inspector/grids/grid-inspector.js b/devtools/client/inspector/grids/grid-inspector.js new file mode 100644 index 0000000000..c6aac90592 --- /dev/null +++ b/devtools/client/inspector/grids/grid-inspector.js @@ -0,0 +1,776 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const flags = require("resource://devtools/shared/flags.js"); +const { throttle } = require("resource://devtools/shared/throttle.js"); + +const gridsReducer = require("resource://devtools/client/inspector/grids/reducers/grids.js"); +const highlighterSettingsReducer = require("resource://devtools/client/inspector/grids/reducers/highlighter-settings.js"); +const { + updateGridColor, + updateGridHighlighted, + updateGrids, +} = require("resource://devtools/client/inspector/grids/actions/grids.js"); +const { + updateShowGridAreas, + updateShowGridLineNumbers, + updateShowInfiniteLines, +} = require("resource://devtools/client/inspector/grids/actions/highlighter-settings.js"); + +loader.lazyRequireGetter( + this, + "compareFragmentsGeometry", + "resource://devtools/client/inspector/grids/utils/utils.js", + true +); +loader.lazyRequireGetter( + this, + "parseURL", + "resource://devtools/client/shared/source-utils.js", + true +); +loader.lazyRequireGetter( + this, + "asyncStorage", + "resource://devtools/shared/async-storage.js" +); + +const CSS_GRID_COUNT_HISTOGRAM_ID = "DEVTOOLS_NUMBER_OF_CSS_GRIDS_IN_A_PAGE"; + +const SHOW_GRID_AREAS = "devtools.gridinspector.showGridAreas"; +const SHOW_GRID_LINE_NUMBERS = "devtools.gridinspector.showGridLineNumbers"; +const SHOW_INFINITE_LINES_PREF = "devtools.gridinspector.showInfiniteLines"; + +const TELEMETRY_GRID_AREAS_OVERLAY_CHECKED = + "devtools.grid.showGridAreasOverlay.checked"; +const TELEMETRY_GRID_LINE_NUMBERS_CHECKED = + "devtools.grid.showGridLineNumbers.checked"; +const TELEMETRY_INFINITE_LINES_CHECKED = + "devtools.grid.showInfiniteLines.checked"; + +// Default grid colors. +const GRID_COLORS = [ + "#9400FF", + "#DF00A9", + "#0A84FF", + "#12BC00", + "#EA8000", + "#00B0BD", + "#D70022", + "#4B42FF", + "#B5007F", + "#058B00", + "#A47F00", + "#005A71", +]; + +class GridInspector { + constructor(inspector, window) { + this.document = window.document; + this.inspector = inspector; + this.store = inspector.store; + this.telemetry = inspector.telemetry; + + // Maximum number of grid highlighters that can be displayed. + this.maxHighlighters = Services.prefs.getIntPref( + "devtools.gridinspector.maxHighlighters" + ); + + this.store.injectReducer("grids", gridsReducer); + this.store.injectReducer("highlighterSettings", highlighterSettingsReducer); + + this.onHighlighterShown = this.onHighlighterShown.bind(this); + this.onHighlighterHidden = this.onHighlighterHidden.bind(this); + this.onNavigate = this.onNavigate.bind(this); + this.onReflow = throttle(this.onReflow, 500, this); + this.onSetGridOverlayColor = this.onSetGridOverlayColor.bind(this); + this.onSidebarSelect = this.onSidebarSelect.bind(this); + this.onToggleGridHighlighter = this.onToggleGridHighlighter.bind(this); + this.onToggleShowGridAreas = this.onToggleShowGridAreas.bind(this); + this.onToggleShowGridLineNumbers = + this.onToggleShowGridLineNumbers.bind(this); + this.onToggleShowInfiniteLines = this.onToggleShowInfiniteLines.bind(this); + this.updateGridPanel = this.updateGridPanel.bind(this); + this.listenForGridHighlighterEvents = + this.listenForGridHighlighterEvents.bind(this); + + this.init(); + } + + get highlighters() { + if (!this._highlighters) { + this._highlighters = this.inspector.highlighters; + } + + return this._highlighters; + } + + /** + * Initializes the grid inspector by fetching the LayoutFront from the walker and + * loading the highlighter settings. + */ + async init() { + if (!this.inspector) { + return; + } + + if (flags.testing) { + // In tests, we start listening immediately to avoid having to simulate a mousemove. + this.listenForGridHighlighterEvents(); + } else { + this.document.addEventListener( + "mousemove", + this.listenForGridHighlighterEvents, + { + once: true, + } + ); + } + + this.inspector.sidebar.on("select", this.onSidebarSelect); + this.inspector.on("new-root", this.onNavigate); + + this.onSidebarSelect(); + } + + listenForGridHighlighterEvents() { + this.highlighters.on("grid-highlighter-hidden", this.onHighlighterHidden); + this.highlighters.on("grid-highlighter-shown", this.onHighlighterShown); + } + + /** + * Get the LayoutActor fronts for all interesting targets where we have inspectors. + * + * @return {Array} The list of LayoutActor fronts + */ + async getLayoutFronts() { + const inspectorFronts = await this.inspector.getAllInspectorFronts(); + const layoutFronts = await Promise.all( + inspectorFronts.map(({ walker }) => walker.getLayoutInspector()) + ); + return layoutFronts.filter(front => !front.isDestroyed()); + } + + /** + * Destruction function called when the inspector is destroyed. Removes event listeners + * and cleans up references. + */ + destroy() { + if (this._highlighters) { + this.highlighters.off( + "grid-highlighter-hidden", + this.onHighlighterHidden + ); + this.highlighters.off("grid-highlighter-shown", this.onHighlighterShown); + } + this.document.removeEventListener( + "mousemove", + this.listenForGridHighlighterEvents + ); + + this.inspector.sidebar.off("select", this.onSidebarSelect); + this.inspector.off("new-root", this.onNavigate); + + this.inspector.off("reflow-in-selected-target", this.onReflow); + + this._highlighters = null; + this.document = null; + this.inspector = null; + this.store = null; + } + + getComponentProps() { + return { + onSetGridOverlayColor: this.onSetGridOverlayColor, + onToggleGridHighlighter: this.onToggleGridHighlighter, + onToggleShowGridAreas: this.onToggleShowGridAreas, + onToggleShowGridLineNumbers: this.onToggleShowGridLineNumbers, + onToggleShowInfiniteLines: this.onToggleShowInfiniteLines, + }; + } + + /** + * Returns the initial color linked to a grid container. Will attempt to check the + * current grid highlighter state and the store. + * + * @param {NodeFront} nodeFront + * The NodeFront for which we need the color. + * @param {String} customColor + * The color fetched from the custom palette, if it exists. + * @param {String} fallbackColor + * The color to use if no color could be found for the node front. + * @return {String} color + * The color to use. + */ + getInitialGridColor(nodeFront, customColor, fallbackColor) { + const highlighted = this.highlighters.gridHighlighters.has(nodeFront); + + let color; + if (customColor) { + color = customColor; + } else if ( + highlighted && + this.highlighters.state.grids.has(nodeFront.actorID) + ) { + // If the node front is currently highlighted, use the color from the highlighter + // options. + color = this.highlighters.state.grids.get(nodeFront.actorID).options + .color; + } else { + // Otherwise use the color defined in the store for this node front. + color = this.getGridColorForNodeFront(nodeFront); + } + + return color || fallbackColor; + } + + /** + * Returns the color set for the grid highlighter associated with the provided + * nodeFront. + * + * @param {NodeFront} nodeFront + * The NodeFront for which we need the color. + */ + getGridColorForNodeFront(nodeFront) { + const { grids } = this.store.getState(); + + for (const grid of grids) { + if (grid.nodeFront === nodeFront) { + return grid.color; + } + } + + return null; + } + + /** + * Given a list of new grid fronts, and if there are highlighted grids, check + * if their fragments have changed. + * + * @param {Array} newGridFronts + * A list of GridFront objects. + * @return {Boolean} + */ + haveCurrentFragmentsChanged(newGridFronts) { + const gridHighlighters = this.highlighters.gridHighlighters; + + if (!gridHighlighters.size) { + return false; + } + + const gridFronts = newGridFronts.filter(g => + gridHighlighters.has(g.containerNodeFront) + ); + if (!gridFronts.length) { + return false; + } + + const { grids } = this.store.getState(); + + for (const node of gridHighlighters.keys()) { + const oldFragments = grids.find(g => g.nodeFront === node).gridFragments; + const newFragments = newGridFronts.find( + g => g.containerNodeFront === node + ).gridFragments; + + if (!compareFragmentsGeometry(oldFragments, newFragments)) { + return true; + } + } + + return false; + } + + /** + * Returns true if the layout panel is visible, and false otherwise. + */ + isPanelVisible() { + return ( + this.inspector && + this.inspector.toolbox && + this.inspector.sidebar && + this.inspector.toolbox.currentToolId === "inspector" && + this.inspector.sidebar.getCurrentTabID() === "layoutview" + ); + } + + /** + * Updates the grid panel by dispatching the new grid data. This is called when the + * layout view becomes visible or the view needs to be updated with new grid data. + */ + async updateGridPanel() { + // Stop refreshing if the inspector or store is already destroyed. + if (!this.inspector || !this.store) { + return; + } + + try { + await this._updateGridPanel(); + } catch (e) { + this._throwUnlessDestroyed( + e, + "Inspector destroyed while executing updateGridPanel" + ); + } + } + + async _updateGridPanel() { + const gridFronts = await this.getGrids(); + + if (!gridFronts.length) { + try { + this.store.dispatch(updateGrids([])); + this.inspector.emit("grid-panel-updated"); + return; + } catch (e) { + // This call might fail if called asynchrously after the toolbox is finished + // closing. + return; + } + } + + const currentUrl = this.inspector.currentTarget.url; + + // Log how many CSS Grid elements DevTools sees. + if (currentUrl != this.inspector.previousURL) { + this.telemetry + .getHistogramById(CSS_GRID_COUNT_HISTOGRAM_ID) + .add(gridFronts.length); + this.inspector.previousURL = currentUrl; + } + + // Get the hostname, if there is no hostname, fall back on protocol + // ex: `data:` uri, and `about:` pages + const hostname = + parseURL(currentUrl).hostname || parseURL(currentUrl).protocol; + const customColors = + (await asyncStorage.getItem("gridInspectorHostColors")) || {}; + + const grids = []; + for (let i = 0; i < gridFronts.length; i++) { + const grid = gridFronts[i]; + let nodeFront = grid.containerNodeFront; + + // If the GridFront didn't yet have access to the NodeFront for its container, then + // get it from the walker. This happens when the walker hasn't yet seen this + // particular DOM Node in the tree yet, or when we are connected to an older server. + if (!nodeFront) { + try { + nodeFront = await grid.walkerFront.getNodeFromActor(grid.actorID, [ + "containerEl", + ]); + } catch (e) { + // This call might fail if called asynchrously after the toolbox is finished + // closing. + return; + } + } + + const colorForHost = customColors[hostname] + ? customColors[hostname][i] + : null; + const fallbackColor = GRID_COLORS[i % GRID_COLORS.length]; + const color = this.getInitialGridColor( + nodeFront, + colorForHost, + fallbackColor + ); + const highlighted = this.highlighters.gridHighlighters.has(nodeFront); + const disabled = + !highlighted && + this.maxHighlighters > 1 && + this.highlighters.gridHighlighters.size === this.maxHighlighters; + const isSubgrid = grid.isSubgrid; + const gridData = { + id: i, + actorID: grid.actorID, + color, + disabled, + direction: grid.direction, + gridFragments: grid.gridFragments, + highlighted, + isSubgrid, + nodeFront, + parentNodeActorID: null, + subgrids: [], + writingMode: grid.writingMode, + }; + + if (isSubgrid) { + let parentGridNodeFront; + + try { + parentGridNodeFront = await nodeFront.walkerFront.getParentGridNode( + nodeFront + ); + } catch (e) { + // This call might fail if called asynchrously after the toolbox is finished + // closing. + return; + } + + if (!parentGridNodeFront) { + return; + } + + const parentIndex = grids.findIndex( + g => g.nodeFront.actorID === parentGridNodeFront.actorID + ); + gridData.parentNodeActorID = parentGridNodeFront.actorID; + grids[parentIndex].subgrids.push(gridData.id); + } + + grids.push(gridData); + } + + // We need to make sure that nested subgrids are displayed above their parent grid + // containers, so update the z-index of each grid before rendering them. + for (const root of grids.filter(g => !g.parentNodeActorID)) { + this._updateZOrder(grids, root); + } + + this.store.dispatch(updateGrids(grids)); + this.inspector.emit("grid-panel-updated"); + } + + /** + * Get all GridFront instances from the server(s). + * + * + * @return {Array} The list of GridFronts + */ + async getGrids() { + const promises = []; + try { + const layoutFronts = await this.getLayoutFronts(); + for (const layoutFront of layoutFronts) { + promises.push(layoutFront.getAllGrids()); + } + } catch (e) { + // This call might fail if called asynchrously after the toolbox is finished closing + } + + const gridFronts = (await Promise.all(promises)).flat(); + return gridFronts; + } + + /** + * Handler for "grid-highlighter-shown" events emitted from the + * HighlightersOverlay. Passes nodefront and event name to handleHighlighterChange. + * Required since on and off events need the same reference object. + * + * @param {NodeFront} nodeFront + * The NodeFront of the grid container element for which the grid + * highlighter is shown for. + */ + onHighlighterShown(nodeFront) { + this.onHighlighterChange(nodeFront, true); + } + + /** + * Handler for "grid-highlighter-hidden" events emitted from the + * HighlightersOverlay. Passes nodefront and event name to handleHighlighterChange. + * Required since on and off events need the same reference object. + * + * @param {NodeFront} nodeFront + * The NodeFront of the grid container element for which the grid highlighter + * is hidden for. + */ + onHighlighterHidden(nodeFront) { + this.onHighlighterChange(nodeFront, false); + } + + /** + * Handler for "grid-highlighter-shown" and "grid-highlighter-hidden" events emitted + * from the HighlightersOverlay. Updates the NodeFront's grid highlighted state. + * + * @param {NodeFront} nodeFront + * The NodeFront of the grid container element for which the grid highlighter + * is shown for. + * @param {Boolean} highlighted + * If the grid should be updated to highlight or hide. + */ + onHighlighterChange(nodeFront, highlighted) { + if (!this.isPanelVisible()) { + return; + } + + const { grids } = this.store.getState(); + const grid = grids.find(g => g.nodeFront === nodeFront); + + if (!grid || grid.highlighted === highlighted) { + return; + } + + this.store.dispatch(updateGridHighlighted(nodeFront, highlighted)); + } + + /** + * Handler for "new-root" event fired by the inspector, which indicates a page + * navigation. Updates grid panel contents. + */ + onNavigate() { + if (this.isPanelVisible()) { + this.updateGridPanel(); + } + } + + /** + * Handler for reflow events fired by the inspector when a node is selected. On reflows, + * update the grid panel content, because the shape or number of grids on the page may + * have changed. + * + * Note that there may be frequent reflows on the page and that not all of them actually + * cause the grids to change. So, we want to limit how many times we update the grid + * panel to only reflows that actually either change the list of grids, or those that + * change the current outlined grid. + * To achieve this, this function compares the list of grid containers from before and + * after the reflow, as well as the grid fragment data on the currently highlighted + * grid. + */ + async onReflow() { + try { + if (!this.isPanelVisible()) { + return; + } + + // The list of grids currently displayed. + const { grids } = this.store.getState(); + + // The new list of grids from the server. + const newGridFronts = await this.getGrids(); + + // In some cases, the nodes for current grids may have been removed from the DOM in + // which case we need to update. + if (grids.length && grids.some(grid => !grid.nodeFront.actorID)) { + await this.updateGridPanel(newGridFronts); + return; + } + + // Get the node front(s) from the current grid(s) so we can compare them to them to + // the node(s) of the new grids. + const oldNodeFronts = grids.map(grid => grid.nodeFront.actorID); + const newNodeFronts = newGridFronts + .filter(grid => grid.containerNode) + .map(grid => grid.containerNodeFront.actorID); + + if ( + grids.length === newGridFronts.length && + oldNodeFronts.sort().join(",") == newNodeFronts.sort().join(",") && + !this.haveCurrentFragmentsChanged(newGridFronts) + ) { + // Same list of containers and the geometry of all the displayed grids remained the + // same, we can safely abort. + return; + } + + // Either the list of containers or the current fragments have changed, do update. + await this.updateGridPanel(newGridFronts); + } catch (e) { + this._throwUnlessDestroyed( + e, + "Inspector destroyed while executing onReflow callback" + ); + } + } + + /** + * Handler for a change in the grid overlay color picker for a grid container. + * + * @param {NodeFront} node + * The NodeFront of the grid container element for which the grid color is + * being updated. + * @param {String} color + * A hex string representing the color to use. + */ + async onSetGridOverlayColor(node, color) { + this.store.dispatch(updateGridColor(node, color)); + + const { grids } = this.store.getState(); + const currentUrl = this.inspector.currentTarget.url; + // Get the hostname, if there is no hostname, fall back on protocol + // ex: `data:` uri, and `about:` pages + const hostname = + parseURL(currentUrl).hostname || parseURL(currentUrl).protocol; + const customGridColors = + (await asyncStorage.getItem("gridInspectorHostColors")) || {}; + + for (const grid of grids) { + if (grid.nodeFront === node) { + if (!customGridColors[hostname]) { + customGridColors[hostname] = []; + } + // Update the custom color for the grid in this position. + customGridColors[hostname][grid.id] = color; + await asyncStorage.setItem("gridInspectorHostColors", customGridColors); + + if (!this.isPanelVisible()) { + // This call might fail if called asynchrously after the toolbox is finished + // closing. + return; + } + + // If the grid for which the color was updated currently has a highlighter, update + // the color. If the node is not explicitly highlighted, we assume it's the + // parent grid for a subgrid. + if (this.highlighters.gridHighlighters.has(node)) { + this.highlighters.showGridHighlighter(node); + } else { + this.highlighters.showParentGridHighlighter(node); + } + } + } + } + + /** + * Handler for the inspector sidebar "select" event. Starts tracking reflows + * if the layout panel is visible. Otherwise, stop tracking reflows. + * Finally, refresh the layout view if it is visible. + */ + onSidebarSelect() { + if (!this.isPanelVisible()) { + this.inspector.off("reflow-in-selected-target", this.onReflow); + return; + } + + this.inspector.on("reflow-in-selected-target", this.onReflow); + this.updateGridPanel(); + } + + /** + * Handler for a change in the input checkboxes in the GridList component. + * Toggles on/off the grid highlighter for the provided grid container element. + * + * @param {NodeFront} node + * The NodeFront of the grid container element for which the grid + * highlighter is toggled on/off for. + */ + onToggleGridHighlighter(node) { + const { grids } = this.store.getState(); + const grid = grids.find(g => g.nodeFront === node); + this.store.dispatch(updateGridHighlighted(node, !grid.highlighted)); + this.highlighters.toggleGridHighlighter(node, "grid"); + } + + /** + * Handler for a change in the show grid areas checkbox in the GridDisplaySettings + * component. Toggles on/off the option to show the grid areas in the grid highlighter. + * Refreshes the shown grid highlighter for the grids currently highlighted. + * + * @param {Boolean} enabled + * Whether or not the grid highlighter should show the grid areas. + */ + onToggleShowGridAreas(enabled) { + this.store.dispatch(updateShowGridAreas(enabled)); + Services.prefs.setBoolPref(SHOW_GRID_AREAS, enabled); + + if (enabled) { + this.telemetry.scalarSet(TELEMETRY_GRID_AREAS_OVERLAY_CHECKED, 1); + } + + const { grids } = this.store.getState(); + + for (const grid of grids) { + if (grid.highlighted) { + this.highlighters.showGridHighlighter(grid.nodeFront); + } + } + } + + /** + * Handler for a change in the show grid line numbers checkbox in the + * GridDisplaySettings component. Toggles on/off the option to show the grid line + * numbers in the grid highlighter. Refreshes the shown grid highlighter for the + * grids currently highlighted. + * + * @param {Boolean} enabled + * Whether or not the grid highlighter should show the grid line numbers. + */ + onToggleShowGridLineNumbers(enabled) { + this.store.dispatch(updateShowGridLineNumbers(enabled)); + Services.prefs.setBoolPref(SHOW_GRID_LINE_NUMBERS, enabled); + + if (enabled) { + this.telemetry.scalarSet(TELEMETRY_GRID_LINE_NUMBERS_CHECKED, 1); + } + + const { grids } = this.store.getState(); + + for (const grid of grids) { + if (grid.highlighted) { + this.highlighters.showGridHighlighter(grid.nodeFront); + } + } + } + + /** + * Handler for a change in the extend grid lines infinitely checkbox in the + * GridDisplaySettings component. Toggles on/off the option to extend the grid + * lines infinitely in the grid highlighter. Refreshes the shown grid highlighter + * for grids currently highlighted. + * + * @param {Boolean} enabled + * Whether or not the grid highlighter should extend grid lines infinitely. + */ + onToggleShowInfiniteLines(enabled) { + this.store.dispatch(updateShowInfiniteLines(enabled)); + Services.prefs.setBoolPref(SHOW_INFINITE_LINES_PREF, enabled); + + if (enabled) { + this.telemetry.scalarSet(TELEMETRY_INFINITE_LINES_CHECKED, 1); + } + + const { grids } = this.store.getState(); + + for (const grid of grids) { + if (grid.highlighted) { + this.highlighters.showGridHighlighter(grid.nodeFront); + } + } + } + + /** + * Some grid-inspector methods are highly asynchronous and might still run + * after the inspector was destroyed. Swallow errors if the grid inspector is + * already destroyed, throw otherwise. + * + * @param {Error} error + * The original error object. + * @param {String} message + * The message to log in case the inspector is already destroyed and + * the error is swallowed. + */ + _throwUnlessDestroyed(error, message) { + if (!this.inspector) { + console.warn(message); + } else { + // If the grid inspector was not destroyed, this is an unexpected error. + throw error; + } + } + + /** + * Set z-index of each grids so that nested subgrids are always above their parent grid + * container. + * + * @param {Array} grids + * A list of grid data. + * @param {Object} parent + * A grid data of parent. + * @param {Number} zIndex + * z-index for the parent. + */ + _updateZOrder(grids, parent, zIndex = 0) { + parent.zIndex = zIndex; + + for (const childIndex of parent.subgrids) { + // Recurse into children grids. + this._updateZOrder(grids, grids[childIndex], zIndex + 1); + } + } +} + +module.exports = GridInspector; |