/* 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"; const { RESET_CHANGES, TRACK_CHANGE, } = require("resource://devtools/client/inspector/changes/actions/index.js"); /** * Return a deep clone of the given state object. * * @param {Object} state * @return {Object} */ function cloneState(state = {}) { return Object.entries(state).reduce((sources, [sourceId, source]) => { sources[sourceId] = { ...source, rules: Object.entries(source.rules).reduce((rules, [ruleId, rule]) => { rules[ruleId] = { ...rule, selectors: rule.selectors.slice(0), children: rule.children.slice(0), add: rule.add.slice(0), remove: rule.remove.slice(0), }; return rules; }, {}), }; return sources; }, {}); } /** * Given information about a CSS rule and its ancestor rules (@media, @supports, etc), * create entries in the given rules collection for each rule and assign parent/child * dependencies. * * @param {Object} ruleData * Information about a CSS rule: * { * id: {String} * Unique rule id. * selectors: {Array} * Array of CSS selector text * ancestors: {Array} * Flattened CSS rule tree of the rule's ancestors with the root rule * at the beginning of the array and the leaf rule at the end. * ruleIndex: {Array} * Indexes of each ancestor rule within its parent rule. * } * * @param {Object} rules * Collection of rules to be mutated. * This is a reference to the corresponding `rules` object from the state. * * @return {Object} * Entry for the CSS rule created the given collection of rules. */ function createRule(ruleData, rules) { // Append the rule data to the flattened CSS rule tree with its ancestors. const ruleAncestry = [...ruleData.ancestors, { ...ruleData }]; return ( ruleAncestry .map((rule, index) => { // Ensure each rule has ancestors excluding itself (expand the flattened rule tree). rule.ancestors = ruleAncestry.slice(0, index); // Ensure each rule has a selector text. // For the purpose of displaying in the UI, we treat at-rules as selectors. if (!rule.selectors || !rule.selectors.length) { // Display the @type label if there's one let selector = rule.typeName ? rule.typeName + " " : ""; selector += rule.conditionText || rule.name || rule.keyText || rule.selectorText; rule.selectors = [selector]; } return rule.id; }) // Then, create new entries in the rules collection and assign dependencies. .map((ruleId, index, array) => { const { selectors } = ruleAncestry[index]; const prevRuleId = array[index - 1]; const nextRuleId = array[index + 1]; // Create an entry for this ruleId if one does not exist. if (!rules[ruleId]) { rules[ruleId] = { ruleId, isNew: false, selectors, add: [], remove: [], children: [], parent: null, }; } // The next ruleId is lower in the rule tree, therefore it's a child of this rule. if (nextRuleId && !rules[ruleId].children.includes(nextRuleId)) { rules[ruleId].children.push(nextRuleId); } // The previous ruleId is higher in the rule tree, therefore it's the parent. if (prevRuleId) { rules[ruleId].parent = prevRuleId; } return rules[ruleId]; }) // Finally, return the last rule in the array which is the rule we set out to create. .pop() ); } function removeRule(ruleId, rules) { const rule = rules[ruleId]; // First, remove this rule's id from its parent's list of children if (rule.parent && rules[rule.parent]) { rules[rule.parent].children = rules[rule.parent].children.filter( childRuleId => { return childRuleId !== ruleId; } ); // Remove the parent rule if it has no children left. if (!rules[rule.parent].children.length) { removeRule(rule.parent, rules); } } delete rules[ruleId]; } /** * Aggregated changes grouped by sources (stylesheet/element), which contain rules, * which contain collections of added and removed CSS declarations. * * Structure: * : { * type: // {String} One of: "stylesheet", "inline" or "element" * href: // {String|null} Stylesheet or document URL; null for inline stylesheets * rules: { * : { * ruleId: // {String} of this rule * isNew: // {Boolean} Whether the tracked rule was created at runtime, * // meaning it didn't originally exist in the source. * selectors: // {Array} of CSS selectors or CSS at-rule text. * // The array has just one item if the selector is never * // changed. When the rule's selector is changed, the new * // selector is pushed onto this array. * children: [] // {Array} of for child rules of this rule * parent: // {String} of the parent rule * add: [ // {Array} of objects with CSS declarations * { * property: // {String} CSS property name * value: // {String} CSS property value * index: // {Number} Position of the declaration within its CSS rule * } * ... // more declarations * ], * remove: [] // {Array} of objects with CSS declarations * } * ... // more rules * } * } * ... // more sources */ const INITIAL_STATE = {}; const reducers = { /** * CSS changes are collected on the server by the ChangesActor which dispatches them to * the client as atomic operations: a rule/declaration updated, added or removed. * * By design, the ChangesActor has no big-picture context of all the collected changes. * It only holds the stack of atomic changes. This makes it roboust for many use cases: * building a diff-view, supporting undo/redo, offline persistence, etc. Consumers, * like the Changes panel, get to massage the data for their particular purposes. * * Here in the reducer, we aggregate incoming changes to build a human-readable diff * shown in the Changes panel. * - added / removed declarations are grouped by CSS rule. Rules are grouped by their * parent rules (@media, @supports, @keyframes, etc.); Rules belong to sources * (stylesheets, inline styles) * - declarations have an index corresponding to their position in the CSS rule. This * allows tracking of multiple declarations with the same property name. * - repeated changes a declaration will show only the original removal and the latest * addition; * - when a declaration is removed, we update the indices of other tracked declarations * in the same rule which may have changed position in the rule as a result; * - changes which cancel each other out (i.e. return to original) are both removed * from the store; * - when changes cancel each other out leaving the rule unchanged, the rule is removed * from the store. Its parent rule is removed as well if it too ends up unchanged. */ // eslint-disable-next-line complexity [TRACK_CHANGE](state, { change }) { const defaults = { selector: null, source: {}, ancestors: [], add: [], remove: [], }; change = { ...defaults, ...change }; state = cloneState(state); const { selector, ancestors, ruleIndex } = change; const sourceId = change.source.id; const ruleId = change.id; // Copy or create object identifying the source (styelsheet/element) for this change. const source = Object.assign({}, state[sourceId], change.source); // Copy or create collection of all rules ever changed in this source. const rules = Object.assign({}, source.rules); // Reference or create object identifying the rule for this change. const rule = rules[ruleId] ? rules[ruleId] : createRule( { id: change.id, selectors: [selector], ancestors, ruleIndex }, rules ); // Mark the rule if it was created at runtime as a result of an "Add Rule" action. if (change.type === "rule-add") { rule.isNew = true; } // If the first selector tracked for this rule is identical to the incoming selector, // reduce the selectors array to a single one. This handles the case for renaming a // selector back to its original name. It has no side effects for other changes which // preserve the selector. // If the rule was created at runtime, always reduce the selectors array to one item. // Changes to the new rule's selector always overwrite the original selector. // If the selectors are different, push the incoming one to the end of the array to // signify that the rule has changed selector. The last item is the current selector. if (rule.selectors[0] === selector || rule.isNew) { rule.selectors = [selector]; } else { rule.selectors.push(selector); } if (change.remove?.length) { for (const decl of change.remove) { // Find the position of any added declaration which matches the incoming // declaration to be removed. const addIndex = rule.add.findIndex(addDecl => { return ( addDecl.index === decl.index && addDecl.property === decl.property && addDecl.value === decl.value ); }); // Find the position of any removed declaration which matches the incoming // declaration to be removed. It's possible to get duplicate remove operations // when, for example, disabling a declaration then deleting it. const removeIndex = rule.remove.findIndex(removeDecl => { return ( removeDecl.index === decl.index && removeDecl.property === decl.property && removeDecl.value === decl.value ); }); // Track the remove operation only if the property was not previously introduced // by an add operation. This ensures repeated changes of the same property // register as a single remove operation of its original value. Avoid tracking the // remove declaration if already tracked (happens on disable followed by delete). if (addIndex < 0 && removeIndex < 0) { rule.remove.push(decl); } // Delete any previous add operation which would be canceled out by this remove. if (rule.add[addIndex]) { rule.add.splice(addIndex, 1); } // Update the indexes of previously tracked declarations which follow this removed // one so future tracking continues to point to the right declarations. if (change.type === "declaration-remove") { rule.add = rule.add.map(addDecl => { if (addDecl.index > decl.index) { addDecl.index--; } return addDecl; }); rule.remove = rule.remove.map(removeDecl => { if (removeDecl.index > decl.index) { removeDecl.index--; } return removeDecl; }); } } } if (change.add?.length) { for (const decl of change.add) { // Find the position of any removed declaration which matches the incoming // declaration to be added. const removeIndex = rule.remove.findIndex(removeDecl => { return ( removeDecl.index === decl.index && removeDecl.value === decl.value && removeDecl.property === decl.property ); }); // Find the position of any added declaration which matches the incoming // declaration to be added in case we need to replace it. const addIndex = rule.add.findIndex(addDecl => { return ( addDecl.index === decl.index && addDecl.property === decl.property ); }); if (rule.remove[removeIndex]) { // Delete any previous remove operation which would be canceled out by this add. rule.remove.splice(removeIndex, 1); } else if (rule.add[addIndex]) { // Replace previous add operation for declaration at this index. rule.add.splice(addIndex, 1, decl); } else { // Track new add operation. rule.add.push(decl); } } } // Remove the rule if none of its declarations or selector have changed, // but skip cleanup if the selector is in process of being renamed (there are two // changes happening in quick succession: selector-remove + selector-add) or if the // rule was created at runtime (allow empty new rules to persist). if ( !rule.add.length && !rule.remove.length && rule.selectors.length === 1 && !change.type.startsWith("selector-") && !rule.isNew ) { removeRule(ruleId, rules); source.rules = { ...rules }; } else { source.rules = { ...rules, [ruleId]: rule }; } // Remove information about the source if none of its rules changed. if (!Object.keys(source.rules).length) { delete state[sourceId]; } else { state[sourceId] = source; } return state; }, [RESET_CHANGES](state) { return INITIAL_STATE; }, }; module.exports = function (state = INITIAL_STATE, action) { const reducer = reducers[action.type]; if (!reducer) { return state; } return reducer(state, action); };