summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/changes/selectors
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/inspector/changes/selectors')
-rw-r--r--devtools/client/inspector/changes/selectors/changes.js261
-rw-r--r--devtools/client/inspector/changes/selectors/moz.build9
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",
+)