tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 };