changes.js (14189B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { 8 RESET_CHANGES, 9 TRACK_CHANGE, 10 } = require("resource://devtools/client/inspector/changes/actions/index.js"); 11 12 /** 13 * Return a deep clone of the given state object. 14 * 15 * @param {object} state 16 * @return {object} 17 */ 18 function cloneState(state = {}) { 19 return Object.entries(state).reduce((sources, [sourceId, source]) => { 20 sources[sourceId] = { 21 ...source, 22 rules: Object.entries(source.rules).reduce((rules, [ruleId, rule]) => { 23 rules[ruleId] = { 24 ...rule, 25 selectors: rule.selectors.slice(0), 26 children: rule.children.slice(0), 27 add: rule.add.slice(0), 28 remove: rule.remove.slice(0), 29 }; 30 31 return rules; 32 }, {}), 33 }; 34 35 return sources; 36 }, {}); 37 } 38 39 /** 40 * Given information about a CSS rule and its ancestor rules (@media, @supports, etc), 41 * create entries in the given rules collection for each rule and assign parent/child 42 * dependencies. 43 * 44 * @param {object} ruleData 45 * Information about a CSS rule: 46 * { 47 * id: {String} 48 * Unique rule id. 49 * selectors: {Array} 50 * Array of CSS selector text 51 * ancestors: {Array} 52 * Flattened CSS rule tree of the rule's ancestors with the root rule 53 * at the beginning of the array and the leaf rule at the end. 54 * ruleIndex: {Array} 55 * Indexes of each ancestor rule within its parent rule. 56 * } 57 * 58 * @param {object} rules 59 * Collection of rules to be mutated. 60 * This is a reference to the corresponding `rules` object from the state. 61 * 62 * @return {object} 63 * Entry for the CSS rule created the given collection of rules. 64 */ 65 function createRule(ruleData, rules) { 66 // Append the rule data to the flattened CSS rule tree with its ancestors. 67 const ruleAncestry = [...ruleData.ancestors, { ...ruleData }]; 68 69 return ( 70 ruleAncestry 71 .map((rule, index) => { 72 // Ensure each rule has ancestors excluding itself (expand the flattened rule tree). 73 rule.ancestors = ruleAncestry.slice(0, index); 74 // Ensure each rule has a selector text. 75 // For the purpose of displaying in the UI, we treat at-rules as selectors. 76 if (!rule.selectors || !rule.selectors.length) { 77 // Display the @type label if there's one 78 let selector = rule.typeName ? rule.typeName + " " : ""; 79 selector += 80 rule.conditionText || 81 rule.name || 82 rule.keyText || 83 rule.selectorText; 84 85 rule.selectors = [selector]; 86 } 87 88 return rule.id; 89 }) 90 // Then, create new entries in the rules collection and assign dependencies. 91 .map((ruleId, index, array) => { 92 const { selectors } = ruleAncestry[index]; 93 const prevRuleId = array[index - 1]; 94 const nextRuleId = array[index + 1]; 95 96 // Create an entry for this ruleId if one does not exist. 97 if (!rules[ruleId]) { 98 rules[ruleId] = { 99 ruleId, 100 isNew: false, 101 selectors, 102 add: [], 103 remove: [], 104 children: [], 105 parent: null, 106 }; 107 } 108 109 // The next ruleId is lower in the rule tree, therefore it's a child of this rule. 110 if (nextRuleId && !rules[ruleId].children.includes(nextRuleId)) { 111 rules[ruleId].children.push(nextRuleId); 112 } 113 114 // The previous ruleId is higher in the rule tree, therefore it's the parent. 115 if (prevRuleId) { 116 rules[ruleId].parent = prevRuleId; 117 } 118 119 return rules[ruleId]; 120 }) 121 // Finally, return the last rule in the array which is the rule we set out to create. 122 .pop() 123 ); 124 } 125 126 function removeRule(ruleId, rules) { 127 const rule = rules[ruleId]; 128 129 // First, remove this rule's id from its parent's list of children 130 if (rule.parent && rules[rule.parent]) { 131 rules[rule.parent].children = rules[rule.parent].children.filter( 132 childRuleId => { 133 return childRuleId !== ruleId; 134 } 135 ); 136 137 // Remove the parent rule if it has no children left. 138 if (!rules[rule.parent].children.length) { 139 removeRule(rule.parent, rules); 140 } 141 } 142 143 delete rules[ruleId]; 144 } 145 146 /** 147 * Aggregated changes grouped by sources (stylesheet/element), which contain rules, 148 * which contain collections of added and removed CSS declarations. 149 * 150 * Structure: 151 * <sourceId>: { 152 * type: // {String} One of: "stylesheet", "inline" or "element" 153 * href: // {String|null} Stylesheet or document URL; null for inline stylesheets 154 * rules: { 155 * <ruleId>: { 156 * ruleId: // {String} <ruleId> of this rule 157 * isNew: // {Boolean} Whether the tracked rule was created at runtime, 158 * // meaning it didn't originally exist in the source. 159 * selectors: // {Array} of CSS selectors or CSS at-rule text. 160 * // The array has just one item if the selector is never 161 * // changed. When the rule's selector is changed, the new 162 * // selector is pushed onto this array. 163 * children: [] // {Array} of <ruleId> for child rules of this rule 164 * parent: // {String} <ruleId> of the parent rule 165 * add: [ // {Array} of objects with CSS declarations 166 * { 167 * property: // {String} CSS property name 168 * value: // {String} CSS property value 169 * index: // {Number} Position of the declaration within its CSS rule 170 * } 171 * ... // more declarations 172 * ], 173 * remove: [] // {Array} of objects with CSS declarations 174 * } 175 * ... // more rules 176 * } 177 * } 178 * ... // more sources 179 */ 180 const INITIAL_STATE = {}; 181 182 const reducers = { 183 /** 184 * CSS changes are collected on the server by the CSSChangeWatcher which sends CSS_CHANGE 185 * resources to the client as atomic operations: a rule/declaration updated, added or removed. 186 * 187 * By design, the CSSChangeWatcher has no big-picture context of all the collected changes. 188 * It only holds the stack of atomic changes. This makes it roboust for many use cases: 189 * building a diff-view, supporting undo/redo, offline persistence, etc. Consumers, 190 * like the Changes panel, get to massage the data for their particular purposes. 191 * 192 * Here in the reducer, we aggregate incoming changes to build a human-readable diff 193 * shown in the Changes panel. 194 * - added / removed declarations are grouped by CSS rule. Rules are grouped by their 195 * parent rules (@media, @supports, @keyframes, etc.); Rules belong to sources 196 * (stylesheets, inline styles) 197 * - declarations have an index corresponding to their position in the CSS rule. This 198 * allows tracking of multiple declarations with the same property name. 199 * - repeated changes a declaration will show only the original removal and the latest 200 * addition; 201 * - when a declaration is removed, we update the indices of other tracked declarations 202 * in the same rule which may have changed position in the rule as a result; 203 * - changes which cancel each other out (i.e. return to original) are both removed 204 * from the store; 205 * - when changes cancel each other out leaving the rule unchanged, the rule is removed 206 * from the store. Its parent rule is removed as well if it too ends up unchanged. 207 */ 208 // eslint-disable-next-line complexity 209 [TRACK_CHANGE](state, { change }) { 210 const defaults = { 211 selector: null, 212 source: {}, 213 ancestors: [], 214 add: [], 215 remove: [], 216 }; 217 218 change = { ...defaults, ...change }; 219 state = cloneState(state); 220 221 const { selector, ancestors, ruleIndex } = change; 222 const sourceId = change.source.id; 223 const ruleId = change.id; 224 225 // Copy or create object identifying the source (styelsheet/element) for this change. 226 const source = Object.assign({}, state[sourceId], change.source); 227 // Copy or create collection of all rules ever changed in this source. 228 const rules = Object.assign({}, source.rules); 229 // Reference or create object identifying the rule for this change. 230 const rule = rules[ruleId] 231 ? rules[ruleId] 232 : createRule( 233 { id: change.id, selectors: [selector], ancestors, ruleIndex }, 234 rules 235 ); 236 237 // Mark the rule if it was created at runtime as a result of an "Add Rule" action. 238 if (change.type === "rule-add") { 239 rule.isNew = true; 240 } 241 242 // If the first selector tracked for this rule is identical to the incoming selector, 243 // reduce the selectors array to a single one. This handles the case for renaming a 244 // selector back to its original name. It has no side effects for other changes which 245 // preserve the selector. 246 // If the rule was created at runtime, always reduce the selectors array to one item. 247 // Changes to the new rule's selector always overwrite the original selector. 248 // If the selectors are different, push the incoming one to the end of the array to 249 // signify that the rule has changed selector. The last item is the current selector. 250 if (rule.selectors[0] === selector || rule.isNew) { 251 rule.selectors = [selector]; 252 } else { 253 rule.selectors.push(selector); 254 } 255 256 if (change.remove?.length) { 257 for (const decl of change.remove) { 258 // Find the position of any added declaration which matches the incoming 259 // declaration to be removed. 260 const addIndex = rule.add.findIndex(addDecl => { 261 return ( 262 addDecl.index === decl.index && 263 addDecl.property === decl.property && 264 addDecl.value === decl.value 265 ); 266 }); 267 268 // Find the position of any removed declaration which matches the incoming 269 // declaration to be removed. It's possible to get duplicate remove operations 270 // when, for example, disabling a declaration then deleting it. 271 const removeIndex = rule.remove.findIndex(removeDecl => { 272 return ( 273 removeDecl.index === decl.index && 274 removeDecl.property === decl.property && 275 removeDecl.value === decl.value 276 ); 277 }); 278 279 // Track the remove operation only if the property was not previously introduced 280 // by an add operation. This ensures repeated changes of the same property 281 // register as a single remove operation of its original value. Avoid tracking the 282 // remove declaration if already tracked (happens on disable followed by delete). 283 if (addIndex < 0 && removeIndex < 0) { 284 rule.remove.push(decl); 285 } 286 287 // Delete any previous add operation which would be canceled out by this remove. 288 if (rule.add[addIndex]) { 289 rule.add.splice(addIndex, 1); 290 } 291 292 // Update the indexes of previously tracked declarations which follow this removed 293 // one so future tracking continues to point to the right declarations. 294 if (change.type === "declaration-remove") { 295 rule.add = rule.add.map(addDecl => { 296 if (addDecl.index > decl.index) { 297 addDecl.index--; 298 } 299 300 return addDecl; 301 }); 302 303 rule.remove = rule.remove.map(removeDecl => { 304 if (removeDecl.index > decl.index) { 305 removeDecl.index--; 306 } 307 308 return removeDecl; 309 }); 310 } 311 } 312 } 313 314 if (change.add?.length) { 315 for (const decl of change.add) { 316 // Find the position of any removed declaration which matches the incoming 317 // declaration to be added. 318 const removeIndex = rule.remove.findIndex(removeDecl => { 319 return ( 320 removeDecl.index === decl.index && 321 removeDecl.value === decl.value && 322 removeDecl.property === decl.property 323 ); 324 }); 325 326 // Find the position of any added declaration which matches the incoming 327 // declaration to be added in case we need to replace it. 328 const addIndex = rule.add.findIndex(addDecl => { 329 return ( 330 addDecl.index === decl.index && addDecl.property === decl.property 331 ); 332 }); 333 334 if (rule.remove[removeIndex]) { 335 // Delete any previous remove operation which would be canceled out by this add. 336 rule.remove.splice(removeIndex, 1); 337 } else if (rule.add[addIndex]) { 338 // Replace previous add operation for declaration at this index. 339 rule.add.splice(addIndex, 1, decl); 340 } else { 341 // Track new add operation. 342 rule.add.push(decl); 343 } 344 } 345 } 346 347 // Remove the rule if none of its declarations or selector have changed, 348 // but skip cleanup if the selector is in process of being renamed (there are two 349 // changes happening in quick succession: selector-remove + selector-add) or if the 350 // rule was created at runtime (allow empty new rules to persist). 351 if ( 352 !rule.add.length && 353 !rule.remove.length && 354 rule.selectors.length === 1 && 355 !change.type.startsWith("selector-") && 356 !rule.isNew 357 ) { 358 removeRule(ruleId, rules); 359 source.rules = { ...rules }; 360 } else { 361 source.rules = { ...rules, [ruleId]: rule }; 362 } 363 364 // Remove information about the source if none of its rules changed. 365 if (!Object.keys(source.rules).length) { 366 delete state[sourceId]; 367 } else { 368 state[sourceId] = source; 369 } 370 371 return state; 372 }, 373 374 [RESET_CHANGES]() { 375 return INITIAL_STATE; 376 }, 377 }; 378 379 module.exports = function (state = INITIAL_STATE, action) { 380 const reducer = reducers[action.type]; 381 if (!reducer) { 382 return state; 383 } 384 return reducer(state, action); 385 };