Source code

Revision control

Copy as Markdown

Other Tools

/* 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,
/**
* 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:
* <sourceId>: {
* type: // {String} One of: "stylesheet", "inline" or "element"
* href: // {String|null} Stylesheet or document URL; null for inline stylesheets
* rules: {
* <ruleId>: {
* ruleId: // {String} <ruleId> 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 <ruleId> for child rules of this rule
* parent: // {String} <ruleId> 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]() {
return INITIAL_STATE;
},
};
module.exports = function (state = INITIAL_STATE, action) {
const reducer = reducers[action.type];
if (!reducer) {
return state;
}
return reducer(state, action);
};