Skip to content
Open
146 changes: 123 additions & 23 deletions packages/core/src/css_composer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
Selectors = Selectors;

storageKey = 'styles';

private _ruleCache = new Map<string, CssRule>();
/**
* Initializes module. Automatically called with a new instance of the editor
* @param {Object} config Configurations
Expand All @@ -104,14 +104,102 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
config.rules = this.em.config.style || config.rules || '';

this.rules = new CssRules([], config);
this._setupListeners();
}

/**
* Initialize event listeners for the rules collection.
*/
private _setupListeners() {
this.em.listenTo(this.rules, 'add', this._onRuleAdd.bind(this));
this.em.listenTo(this.rules, 'remove', this._onRuleRemove.bind(this));
this.em.listenTo(this.rules, 'reset', this._onRulesReset.bind(this));
this.em.listenTo(this.rules, 'change:selectors change:state change:mediaText', this._onRuleKeyChange.bind(this));
}

/**
* Handles the 'add' event on the rules collection.
* @param {CssRule} rule The added rule.
*/
private _onRuleAdd(rule: CssRule) {
this._cacheRule(rule);
}

/**
* Handles the 'remove' event on the rules collection.
* @param {CssRule} rule The removed rule.
*/
private _onRuleRemove(rule: CssRule) {
this._uncacheRule(rule);
}

/**
* Handles the 'reset' event on the rules collection.
* Clears the cache and repopulates it with the new set of rules.
* @param {CssRules} rules The new collection of rules.
*/
private _onRulesReset(rules: CssRules) {
this._clearRuleCache();
rules.each((rule) => this._cacheRule(rule));
}

/**
* Handles changes to rule properties that affect the cache key (eg. renaming a selector).
* This method finds the old cache entry by its value (the rule instance), removes it,
* and then re-caches the rule with its new key.
* @param {CssRule} rule The changed rule.
*/
private _onRuleKeyChange(rule: CssRule) {
let oldKey: string | undefined;
// Find the old cache entry by iterating through the cache values
for (const [key, cachedRule] of (this._ruleCache as any).entries()) {
if (cachedRule === rule) {
oldKey = key;
break;
}
}

if (oldKey) {
this._ruleCache.delete(oldKey);
}

this._cacheRule(rule);
}

private _makeCacheKey(selectors: any, state?: string, width?: string) {
const sels = Array.isArray(selectors)
? selectors.map((s) => (typeof s === 'string' ? s : s.toString())).join(',')
: typeof selectors === 'string'
? selectors
: selectors?.toString() || '';
return `${sels}|${state || ''}|${width || ''}`;
}

/** Add rule to cache */
private _cacheRule(rule: CssRule) {
const key = this._makeCacheKey(rule.getSelectorsString(), rule.get('state'), rule.get('mediaText'));
this._ruleCache.set(key, rule);
}

/** Remove a rule from cache */
private _uncacheRule(rule: CssRule) {
const key = this._makeCacheKey(rule.getSelectorsString(), rule.get('state'), rule.get('mediaText'));
this._ruleCache.delete(key);
}

/** Clear all cache entries */
private _clearRuleCache() {
this._ruleCache.clear();
}

/**
* On load callback
* @private
*/
onLoad() {
this._setupListeners();
this.rules.add(this.config.rules, { silent: true });
this._onRulesReset(this.rules);
}

/**
Expand Down Expand Up @@ -157,27 +245,29 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
const s = state || '';
const w = width || '';
const opt = { ...opts } as CssRuleProperties;
let rule = this.get(selectors, s, w, opt);
const key = this._makeCacheKey(selectors, s, w);

// do not create rules that were found before
// unless this is a single at-rule, for which multiple declarations
// make sense (e.g. multiple `@font-type`s)
if (rule && rule.config && !rule.config.singleAtRule) {
return rule;
} else {
opt.state = s;
opt.mediaText = w;
opt.selectors = [];
// #4727: Prevent updating atRuleType if already defined
if (w && !opt.atRuleType) {
opt.atRuleType = 'media';
}
rule = new CssRule(opt, this.config);
// @ts-ignore
rule.get('selectors').add(selectors, addOpts);
this.rules.add(rule, addOpts);
return rule;
const cached = this._ruleCache.get(key);
if (cached && cached.config && !cached.config.singleAtRule) {
return cached;
}

let rule = this.get(selectors, s, w, opt);
if (rule && rule.config && !rule.config.singleAtRule) return rule;

opt.state = s;
opt.mediaText = w;
opt.selectors = [];
if (w && !opt.atRuleType) opt.atRuleType = 'media';

rule = new CssRule(opt, this.config);
// @ts-ignore
rule.get('selectors').add(selectors, addOpts);
this.rules.add(rule, addOpts);

this._cacheRule(rule);

return rule;
}

/**
Expand Down Expand Up @@ -205,14 +295,21 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
width?: string,
ruleProps?: Omit<CssRuleProperties, 'selectors'>,
): CssRule | undefined {
const key = this._makeCacheKey(selectors, state, width);
const cached = this._ruleCache.get(key);
if (cached) return cached;

let slc = selectors;
if (isString(selectors)) {
const sm = this.em.Selectors;
const singleSel = selectors.split(',')[0].trim();
const node = this.em.Parser.parserCss.checkNode({ selectors: singleSel } as any)[0];
slc = sm.get(node.selectors as string[]);
}
return this.rules.find((rule) => rule.compare(slc, state, width, ruleProps)) || null;

const rule = this.rules.find((r) => r.compare(slc, state, width, ruleProps)) || null;
if (rule) this._cacheRule(rule);
return rule;
}

getAll() {
Expand Down Expand Up @@ -485,15 +582,18 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
*/
remove(rule: string | CssRule, opts?: any) {
const toRemove = isString(rule) ? this.getRules(rule) : rule;
const result = this.getAll().remove(toRemove, opts);
return isArray(result) ? result : [result];
const arr = Array.isArray(toRemove) ? toRemove : [toRemove];
arr.forEach((r) => this._uncacheRule(r));
const result = this.getAll().remove(arr, opts);
return Array.isArray(result) ? result : [result];
}

/**
* Remove all rules
* @return {this}
*/
clear(opts = {}) {
this._clearRuleCache();
this.getAll().reset([], opts);
return this;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/css_composer/model/CssRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,12 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
* cssRule.getAtRule(); // "@media (min-width: 500px)"
*/
getAtRule() {
const type = this.get('atRuleType');
const condition = this.get('mediaText');
// Avoid breaks with the last condition
return CssRule.getAtRuleFromProps(this.attributes);
}

static getAtRuleFromProps(cssRuleProps: Partial<CssRuleProperties>) {
const type = cssRuleProps.atRuleType;
const condition = cssRuleProps.mediaText;
const typeStr = type ? `@${type}` : condition ? '@media' : '';

return typeStr + (condition && typeStr ? ` ${condition}` : '');
Expand Down
23 changes: 15 additions & 8 deletions packages/core/src/dom_components/model/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @private
* @ts-ignore */
collection!: Components;
__traitsInited = false;

constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
const em = opt.em;
Expand Down Expand Up @@ -2097,7 +2098,6 @@ export default class Component extends StyleableModel<ComponentProperties> {

static getNewId(list: ObjectAny) {
const count = Object.keys(list).length;
// Testing 1000000 components with `+ 2` returns 0 collisions
const ilen = count.toString().length + 2;
const uid = (Math.random() + 1.1).toString(36).slice(-ilen);
let newId = `i${uid}`;
Expand All @@ -2111,16 +2111,23 @@ export default class Component extends StyleableModel<ComponentProperties> {

static getIncrementId(id: string, list: ObjectAny, opts: { keepIds?: string[] } = {}) {
const { keepIds = [] } = opts;
let counter = 1;
let newId = id;
const keepIdsSet = new Set(keepIds);

if (keepIds.indexOf(id) < 0) {
while (list[newId]) {
counter++;
newId = `${id}-${counter}`;
}
if (keepIdsSet.has(id)) {
return id;
}

let newId = id;
if (!list[newId]) {
return newId;
}

let counter = 1;
do {
counter++;
newId = `${id}-${counter}`;
} while (list[newId]);

return newId;
}

Expand Down
50 changes: 38 additions & 12 deletions packages/core/src/dom_components/model/ModelDataResolverWatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,29 +34,55 @@ export class ModelDataResolverWatchers<T extends StyleableModelProperties> {
}

addProps(props: ObjectAny, options: DataWatchersOptions = {}) {
const dataValues = props[keyDataValues] ?? {};
const dataValues = props[keyDataValues] || {};
const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource;

const filteredProps = this.filterProps(props);
const evaluatedProps = {
...props,
...this.propertyWatcher.addDataValues({ ...filteredProps, ...dataValues.props }, options),
};
const filteredProps: ObjectAny = {};
for (const k in props) {
if (k !== 'components' && k !== 'dataResolver' && k !== keyDataValues) {
filteredProps[k] = props[k];
}
}

const evaluatedProps = props;
const propDataValues = dataValues.props || {};
const addedValues = this.propertyWatcher.addDataValues(Object.assign({}, filteredProps, propDataValues), options);

for (const key in addedValues) {
evaluatedProps[key] = addedValues[key];
}

if (this.shouldProcessProp('attributes', props, dataValues)) {
evaluatedProps.attributes = this.processAttributes(props, dataValues, options);
evaluatedProps.attributes = this.attributeWatcher.setDataValues(
Object.assign({}, props.attributes || {}, dataValues.attributes || {}),
options,
);
}

if (this.shouldProcessProp('style', props, dataValues)) {
evaluatedProps.style = this.processStyles(props, dataValues, options);
const baseStyle = props.style;
if (typeof baseStyle === 'string') {
this.styleWatcher.removeListeners();
evaluatedProps.style = baseStyle;
} else {
evaluatedProps.style = this.styleWatcher.setDataValues(
Object.assign({}, baseStyle || {}, dataValues.style || {}),
options,
);
}
}

const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!skipOverrideUpdates) {
this.updateSymbolOverride();

const propResolvers = this.propertyWatcher.getAllDataResolvers();
const styleResolvers = this.styleWatcher.getAllDataResolvers();
const attrResolvers = this.attributeWatcher.getAllDataResolvers();

evaluatedProps[keyDataValues] = {
props: this.propertyWatcher.getAllDataResolvers(),
style: this.styleWatcher.getAllDataResolvers(),
attributes: this.attributeWatcher.getAllDataResolvers(),
props: propResolvers,
style: styleResolvers,
attributes: attrResolvers,
};
}

Expand Down
Loading