258 lines
8.6 KiB
JavaScript
258 lines
8.6 KiB
JavaScript
/* 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]) => {
|
|
// 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]) => {
|
|
// 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 | 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.values(changeTree).reduce((stylesheetText, 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,
|
|
};
|