diff options
Diffstat (limited to 'devtools/client/inspector/changes/selectors')
-rw-r--r-- | devtools/client/inspector/changes/selectors/changes.js | 261 | ||||
-rw-r--r-- | devtools/client/inspector/changes/selectors/moz.build | 9 |
2 files changed, 270 insertions, 0 deletions
diff --git a/devtools/client/inspector/changes/selectors/changes.js b/devtools/client/inspector/changes/selectors/changes.js new file mode 100644 index 0000000000..a6b99e4579 --- /dev/null +++ b/devtools/client/inspector/changes/selectors/changes.js @@ -0,0 +1,261 @@ +/* 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, + "getTabPrefs", + "resource://devtools/shared/indentation.js", + true +); + +const { + getSourceForDisplay, +} = require("resource://devtools/client/inspector/changes/utils/changes-utils.js"); + +/** + * In the Redux state, changed CSS rules are grouped by source (stylesheet) and stored in + * a single level array, regardless of nesting. + * This method returns a nested tree structure of the changed CSS rules so the React + * consumer components can traverse it easier when rendering the nested CSS rules view. + * Keeping this interface updated allows the Redux state structure to change without + * affecting the consumer components. + * + * @param {Object} state + * Redux slice for tracked changes. + * @param {Object} filter + * Object with optional filters to use. Has the following properties: + * - sourceIds: {Array} + * Use only subtrees of sources matching source ids from this array. + * - ruleIds: {Array} + * Use only rules matching rule ids from this array. If the array includes ids + * of ancestor rules (@media, @supports), their nested rules will be included. + * @return {Object} + */ +function getChangesTree(state, filter = {}) { + // Use or assign defaults of sourceId and ruleId arrays by which to filter the tree. + const { sourceIds: sourceIdsFilter = [], ruleIds: rulesIdsFilter = [] } = + filter; + /** + * Recursively replace a rule's array of child rule ids with the referenced child rules. + * Mark visited rules so as not to handle them (and their children) again. + * + * Returns the rule object with expanded children or null if previously visited. + * + * @param {String} ruleId + * @param {Object} rule + * @param {Array} rules + * @param {Set} visitedRules + * @return {Object|null} + */ + function expandRuleChildren(ruleId, rule, rules, visitedRules) { + if (visitedRules.has(ruleId)) { + return null; + } + + visitedRules.add(ruleId); + + return { + ...rule, + children: rule.children.map(childRuleId => + expandRuleChildren(childRuleId, rules[childRuleId], rules, visitedRules) + ), + }; + } + + return Object.entries(state) + .filter(([sourceId, source]) => { + // Use only matching sources if an array to filter by was provided. + if (sourceIdsFilter.length) { + return sourceIdsFilter.includes(sourceId); + } + + return true; + }) + .reduce((sourcesObj, [sourceId, source]) => { + const { rules } = source; + // Log of visited rules in this source. Helps avoid duplication when traversing the + // descendant rule tree. This Set is unique per source. It will be passed down to + // be populated with ids of rules once visited. This ensures that only visited rules + // unique to this source will be skipped and prevents skipping identical rules from + // other sources (ex: rules with the same selector and the same index). + const visitedRules = new Set(); + + // Build a new collection of sources keyed by source id. + sourcesObj[sourceId] = { + ...source, + // Build a new collection of rules keyed by rule id. + rules: Object.entries(rules) + .filter(([ruleId, rule]) => { + // Use only matching rules if an array to filter by was provided. + if (rulesIdsFilter.length) { + return rulesIdsFilter.includes(ruleId); + } + + return true; + }) + .reduce((rulesObj, [ruleId, rule]) => { + // Expand the rule's array of child rule ids with the referenced child rules. + // Skip exposing null values which mean the rule was previously visited + // as part of an ancestor descendant tree. + const expandedRule = expandRuleChildren( + ruleId, + rule, + rules, + visitedRules + ); + if (expandedRule !== null) { + rulesObj[ruleId] = expandedRule; + } + + return rulesObj; + }, {}), + }; + + return sourcesObj; + }, {}); +} + +/** + * Build the CSS text of a stylesheet with the changes aggregated in the Redux state. + * If filters for rule id or source id are provided, restrict the changes to the matching + * sources and rules. + * + * Code comments with the source origin are put above of the CSS rule (or group of + * rules). Removed CSS declarations are written commented out. Added CSS declarations are + * written as-is. + * + * @param {Object} state + * Redux slice for tracked changes. + * @param {Object} filter + * Object with optional source and rule filters. See getChangesTree() + * @return {String} + * CSS stylesheet text. + */ + +// For stylesheet sources, the stylesheet filename and full path are used: +// +// /* styles.css | https://example.com/styles.css */ +// +// .selector { +// /* property: oldvalue; */ +// property: value; +// } + +// For inline stylesheet sources, the stylesheet index and host document URL are used: +// +// /* Inline #1 | https://example.com */ +// +// .selector { +// /* property: oldvalue; */ +// property: value; +// } + +// For element style attribute sources, the unique selector generated for the element +// and the host document URL are used: +// +// /* Element (div) | https://example.com */ +// +// div:nth-child(1) { +// /* property: oldvalue; */ +// property: value; +// } +function getChangesStylesheet(state, filter) { + const changeTree = getChangesTree(state, filter); + // Get user prefs about indentation style. + const { indentUnit, indentWithTabs } = getTabPrefs(); + const indentChar = indentWithTabs + ? "\t".repeat(indentUnit) + : " ".repeat(indentUnit); + + /** + * If the rule has just one item in its array of selector versions, return it as-is. + * If it has more than one, build a string using the first selector commented-out + * and the last selector as-is. This indicates that a rule's selector has changed. + * + * @param {Array} selectors + * History of selector versions if changed over time. + * Array with a single item (the original selector) if never changed. + * @param {Number} level + * Level of nesting within a CSS rule tree. + * @return {String} + */ + function writeSelector(selectors = [], level) { + const indent = indentChar.repeat(level); + let selectorText; + switch (selectors.length) { + case 0: + selectorText = ""; + break; + case 1: + selectorText = `${indent}${selectors[0]}`; + break; + default: + selectorText = + `${indent}/* ${selectors[0]} { */\n` + + `${indent}${selectors[selectors.length - 1]}`; + } + + return selectorText; + } + + function writeRule(ruleId, rule, level) { + // Write nested rules, if any. + let ruleBody = rule.children.reduce((str, childRule) => { + str += writeRule(childRule.ruleId, childRule, level + 1); + return str; + }, ""); + + // Write changed CSS declarations. + ruleBody += writeDeclarations(rule.remove, rule.add, level + 1); + + const indent = indentChar.repeat(level); + const selectorText = writeSelector(rule.selectors, level); + return `\n${selectorText} {${ruleBody}\n${indent}}`; + } + + function writeDeclarations(remove = [], add = [], level) { + const indent = indentChar.repeat(level); + const removals = remove + // Sort declarations in the order in which they exist in the original CSS rule. + .sort((a, b) => a.index > b.index) + .reduce((str, { property, value }) => { + str += `\n${indent}/* ${property}: ${value}; */`; + return str; + }, ""); + + const additions = add + // Sort declarations in the order in which they exist in the original CSS rule. + .sort((a, b) => a.index > b.index) + .reduce((str, { property, value }) => { + str += `\n${indent}${property}: ${value};`; + return str; + }, ""); + + return removals + additions; + } + + // Iterate through all sources in the change tree and build a CSS stylesheet string. + return Object.entries(changeTree).reduce( + (stylesheetText, [sourceId, source]) => { + const { href, rules } = source; + // Write code comment with source origin + stylesheetText += `\n/* ${getSourceForDisplay(source)} | ${href} */\n`; + // Write CSS rules + stylesheetText += Object.entries(rules).reduce((str, [ruleId, rule]) => { + // Add a new like only after top-level rules (level == 0) + str += writeRule(ruleId, rule, 0) + "\n"; + return str; + }, ""); + + return stylesheetText; + }, + "" + ); +} + +module.exports = { + getChangesTree, + getChangesStylesheet, +}; diff --git a/devtools/client/inspector/changes/selectors/moz.build b/devtools/client/inspector/changes/selectors/moz.build new file mode 100644 index 0000000000..f3ea9a1bfc --- /dev/null +++ b/devtools/client/inspector/changes/selectors/moz.build @@ -0,0 +1,9 @@ +# -*- 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( + "changes.js", +) |