summaryrefslogtreecommitdiffstats
path: root/devtools/client/inspector/changes/selectors/changes.js
blob: a6b99e45794c57a5fc179db088b2224f6a1d3f8b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
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,
};