Browse Source

Merge b47341a204 into 6370546f43

pull/6691/merge
Ihor Kaleniuk 2 weeks ago
committed by GitHub
parent
commit
8eb3fef583
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      docs/api/assets.md
  2. 21
      docs/api/datasources.md
  3. 2
      docs/api/device.md
  4. 18
      docs/api/editor.md
  5. 2
      docs/api/selector.md
  6. 1
      packages/core/package.json
  7. 7
      packages/core/src/abstract/CollectionWithCategories.ts
  8. 8
      packages/core/src/asset_manager/index.ts
  9. 5
      packages/core/src/asset_manager/model/Asset.ts
  10. 20
      packages/core/src/asset_manager/model/Assets.ts
  11. 4
      packages/core/src/block_manager/index.ts
  12. 36
      packages/core/src/block_manager/model/Blocks.ts
  13. 2
      packages/core/src/commands/view/OpenAssets.ts
  14. 2
      packages/core/src/css_composer/index.ts
  15. 1
      packages/core/src/css_composer/model/CssRule.ts
  16. 13
      packages/core/src/css_composer/model/CssRules.ts
  17. 2
      packages/core/src/device_manager/index.ts
  18. 6
      packages/core/src/device_manager/model/Devices.ts
  19. 2
      packages/core/src/dom_components/index.ts
  20. 154
      packages/core/src/dom_components/model/Component.ts
  21. 187
      packages/core/src/dom_components/model/Components.ts
  22. 25
      packages/core/src/domain_abstract/model/StyleableModel.ts
  23. 14
      packages/core/src/editor/config/config.ts
  24. 4
      packages/core/src/editor/index.ts
  25. 12
      packages/core/src/editor/model/Editor.ts
  26. 27
      packages/core/src/editor/types.ts
  27. 2
      packages/core/src/pages/index.ts
  28. 4
      packages/core/src/pages/model/Page.ts
  29. 12
      packages/core/src/pages/model/Pages.ts
  30. 323
      packages/core/src/patch_manager/CollectionWithPatches.ts
  31. 216
      packages/core/src/patch_manager/ModelWithPatches.ts
  32. 352
      packages/core/src/patch_manager/index.ts
  33. 90
      packages/core/src/patch_manager/registry.ts
  34. 8
      packages/core/src/selector_manager/index.ts
  35. 4
      packages/core/src/selector_manager/model/Selector.ts
  36. 10
      packages/core/src/selector_manager/model/Selectors.ts
  37. 2
      packages/core/src/trait_manager/model/Trait.ts
  38. 6
      packages/core/src/trait_manager/model/Traits.ts
  39. 222
      packages/core/src/utils/fractionalIndex.ts
  40. 204
      packages/core/test/specs/patch_manager/collection/CollectionWithPatches.js
  41. 252
      packages/core/test/specs/patch_manager/components.js
  42. 92
      packages/core/test/specs/patch_manager/index.js
  43. 146
      packages/core/test/specs/patch_manager/model/ModelWithPatches.js
  44. 32
      packages/core/test/specs/patch_manager/registry.js
  45. 8
      pnpm-lock.yaml

2
docs/api/assets.md

@ -85,6 +85,8 @@ editor.on('asset:custom', ({ container, assets, ... }) => { ... });
editor.on('asset', ({ event, model, ... }) => { ... });
```
* AssetsEventCallback
## Methods
* [open][2]

21
docs/api/datasources.md

@ -113,6 +113,18 @@ const ds = dsm.get('my_data_source_id');
Returns **[DataSource]** Data source.
## getAll
Return all data sources.
### Examples
```javascript
const ds = dsm.getAll();
```
Returns **[Array][8]<[DataSource]>**&#x20;
## getValue
Get value from data sources by path.
@ -121,6 +133,7 @@ Get value from data sources by path.
* `path` **[String][7]** Path to value.
* `defValue` **any** Default value if the path is not found.
* `opts` **{context: Record<[string][7], any>?}?**&#x20;
Returns **any** const value = dsm.getValue('ds\_id.record\_id.propName', 'defaultValue');
@ -139,7 +152,7 @@ Set value in data sources by path.
dsm.setValue('ds_id.record_id.propName', 'new value');
```
Returns **[Boolean][8]** Returns true if the value was set successfully
Returns **[Boolean][9]** Returns true if the value was set successfully
## remove
@ -183,7 +196,7 @@ data record, and optional property path.
Store data sources to a JSON object.
Returns **[Array][9]** Stored data sources.
Returns **[Array][8]** Stored data sources.
## load
@ -209,6 +222,6 @@ Returns **[Object][6]** Loaded data sources.
[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean

2
docs/api/device.md

@ -2,7 +2,7 @@
## Device
**Extends ModelWithPatches**
### Properties

18
docs/api/editor.md

@ -30,6 +30,24 @@ editor.on('undo', () => { ... });
editor.on('redo', () => { ... });
```
* `patch:update` Patch finalized.
```javascript
editor.on('patch:update', (patch) => { ... });
```
* `patch:undo` Patch undo executed.
```javascript
editor.on('patch:undo', (patch) => { ... });
```
* `patch:redo` Patch redo executed.
```javascript
editor.on('patch:redo', (patch) => { ... });
```
* `load` Editor is loaded. At this stage, the project is loaded in the editor and elements in the canvas are rendered.
```javascript

2
docs/api/selector.md

@ -2,7 +2,7 @@
## Selector
**Extends ModelWithPatches**
### Properties

1
packages/core/package.json

@ -30,6 +30,7 @@
"url": "https://github.com/GrapesJS/grapesjs.git"
},
"dependencies": {
"immer": "^10.1.1",
"@types/backbone": "1.4.15",
"backbone": "1.4.1",
"backbone-undo": "0.2.6",

7
packages/core/src/abstract/CollectionWithCategories.ts

@ -1,8 +1,9 @@
import { isString } from 'underscore';
import { Collection, Model } from '../common';
import { Model } from '../common';
import Categories from './ModuleCategories';
import Category, { CategoryProperties } from './ModuleCategory';
import { isObject } from '../utils/mixins';
import CollectionWithPatches from '../patch_manager/CollectionWithPatches';
interface ModelWithCategoryProps {
category?: string | CategoryProperties;
@ -10,7 +11,9 @@ interface ModelWithCategoryProps {
const CATEGORY_KEY = 'category';
export abstract class CollectionWithCategories<T extends Model<ModelWithCategoryProps>> extends Collection<T> {
export abstract class CollectionWithCategories<
T extends Model<ModelWithCategoryProps>,
> extends CollectionWithPatches<T> {
abstract getCategories(): Categories;
initCategory(model: T) {

8
packages/core/src/asset_manager/index.ts

@ -63,7 +63,13 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
*/
constructor(em: EditorModel) {
// @ts-ignore
super(em, 'AssetManager', new Assets([], em), AssetsEvents, defConfig());
super(
em,
'AssetManager',
new Assets([], { em, patchObjectType: 'assets', collectionId: 'global' } as any),
AssetsEvents,
defConfig(),
);
const { all, config } = this;
// @ts-ignore
this.assetsVis = new Assets([]);

5
packages/core/src/asset_manager/model/Asset.ts

@ -1,5 +1,5 @@
import { result } from 'underscore';
import { Model } from '../../common';
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
/**
* @property {String} type Asset type, eg. `'image'`.
@ -7,7 +7,8 @@ import { Model } from '../../common';
*
* @module docsjs.Asset
*/
export default class Asset extends Model {
export default class Asset extends ModelWithPatches {
patchObjectType = 'asset';
static getDefaults() {
return result(this.prototype, 'defaults');
}

20
packages/core/src/asset_manager/model/Assets.ts

@ -1,13 +1,25 @@
import { Collection } from '../../common';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import Asset from './Asset';
import AssetImage from './AssetImage';
import AssetImageView from '../view/AssetImageView';
import TypeableCollection from '../../domain_abstract/model/TypeableCollection';
const TypeableCollectionExt = Collection.extend(TypeableCollection);
export class Assets extends CollectionWithPatches<Asset> {
types: any[] | undefined;
target?: any;
onSelect?: any;
getTypes!: () => any[];
getType!: (id: string) => any;
getBaseType!: () => any;
recognizeType!: (value: any) => any;
addType!: (id: string, definition: any) => void;
export default class Assets extends TypeableCollectionExt<Asset> {}
constructor(models?: any, options?: any) {
super(models, options);
}
}
Object.assign(Assets.prototype, TypeableCollection);
Assets.prototype.types = [
{
id: 'image',
@ -24,3 +36,5 @@ Assets.prototype.types = [
},
},
];
export default Assets;

4
packages/core/src/block_manager/index.ts

@ -61,7 +61,7 @@ export default class BlockManager extends ItemManagerModule<BlockManagerConfig,
constructor(em: EditorModel) {
super(em, 'BlockManager', new Blocks([], { em }), BlocksEvents, defConfig());
this.blocks = this.all;
this.blocksVisible = new Blocks(this.blocks.models, { em });
this.blocksVisible = new Blocks(this.blocks.models, { em, collectionId: 'visible' } as any);
this.categories = new Categories([], { em, events: { update: BlocksEvents.categoryUpdate } });
this.__onAllEvent = debounce(() => this.__trgCustom(), 0);
@ -335,7 +335,7 @@ export default class BlockManager extends ItemManagerModule<BlockManagerConfig,
const toRender = blocks || this.getAll().models;
if (opts.external) {
const collection = new Blocks(toRender, { em });
const collection = new Blocks(toRender, { em, collectionId: 'render' } as any);
return new BlocksView({ collection, categories }, { em, ...config, ...opts }).render().el;
}

36
packages/core/src/block_manager/model/Blocks.ts

@ -1,20 +1,48 @@
import { CollectionWithCategories } from '../../abstract/CollectionWithCategories';
import { isString } from 'underscore';
import { Collection } from '../../common';
import Categories from '../../abstract/ModuleCategories';
import Category from '../../abstract/ModuleCategory';
import { isObject } from '../../utils/mixins';
import EditorModel from '../../editor/model/Editor';
import Block from './Block';
export default class Blocks extends CollectionWithCategories<Block> {
const CATEGORY_KEY = 'category';
export default class Blocks extends Collection<Block> {
em: EditorModel;
constructor(coll: any[], options: { em: EditorModel }) {
super(coll);
super(coll, options);
this.em = options.em;
this.on('add', this.handleAdd);
}
getCategories() {
getCategories(): Categories {
return this.em.Blocks.getCategories();
}
initCategory(model: Block) {
let category = (model as any).get(CATEGORY_KEY);
const isDefined = category instanceof Category;
// Ensure the category exists and it's not already initialized
if (category && !isDefined) {
if (isString(category)) {
category = { id: category, label: category };
} else if (isObject(category) && !category.id) {
category.id = category.label;
}
const catModel = this.getCategories().add(category);
(model as any).set(CATEGORY_KEY, catModel as any, { silent: true });
return catModel;
} else if (isDefined) {
const catModel = category as unknown as Category;
this.getCategories().add(catModel);
return catModel;
}
}
handleAdd(model: Block) {
this.initCategory(model);
}

2
packages/core/src/commands/view/OpenAssets.ts

@ -49,7 +49,7 @@ export default {
am.__trgCustom();
} else {
if (!this.rendered || types) {
let assets: Asset[] = am.getAll().filter((i: Asset) => i);
let assets: Asset[] = am.getAll().filter((i: Asset) => !!i);
if (types && types.length) {
assets = assets.filter((a) => types.indexOf(a.get('type')) !== -1);

2
packages/core/src/css_composer/index.ts

@ -103,7 +103,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
// @ts-ignore
config.rules = this.em.config.style || config.rules || '';
this.rules = new CssRules([], config);
this.rules = new CssRules([], { ...config, em });
this._setupCacheListeners();
}

1
packages/core/src/css_composer/model/CssRule.ts

@ -97,6 +97,7 @@ const { CSS } = hasWin() ? window : {};
* [Component]: component.html
*/
export default class CssRule extends StyleableModel<CssRuleProperties> {
patchObjectType = 'css-rule';
config: CssRuleProperties;
em?: EditorModel;
opt: any;

13
packages/core/src/css_composer/model/CssRules.ts

@ -1,14 +1,15 @@
import { Collection } from '../../common';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import EditorModel from '../../editor/model/Editor';
import CssRule, { CssRuleProperties } from './CssRule';
export default class CssRules extends Collection<CssRule> {
export default class CssRules extends CollectionWithPatches<CssRule> {
editor: EditorModel;
constructor(props: any, opt: any) {
super(props);
const em: EditorModel = opt?.em || opt?.editor;
super(props, { ...opt, em, patchObjectType: 'css-rules', collectionId: opt?.collectionId || 'global' });
// Inject editor
this.editor = opt?.em;
this.editor = em;
// This will put the listener post CssComposer.postLoad
setTimeout(() => {
@ -18,7 +19,7 @@ export default class CssRules extends Collection<CssRule> {
}
toJSON(opts?: any) {
const result = Collection.prototype.toJSON.call(this, opts);
const result = CollectionWithPatches.prototype.toJSON.call(this, opts);
return result.filter((rule: CssRuleProperties) => rule.style && !rule.shallow);
}
@ -38,7 +39,7 @@ export default class CssRules extends Collection<CssRule> {
models = this.editor.get('Parser').parseCss(models);
}
opt.em = this.editor;
return Collection.prototype.add.apply(this, [models, opt]);
return CollectionWithPatches.prototype.add.apply(this, [models, opt]);
}
}

2
packages/core/src/device_manager/index.ts

@ -51,7 +51,7 @@ export default class DeviceManager extends ItemManagerModule<
storageKey = '';
constructor(em: EditorModel) {
super(em, 'DeviceManager', new Devices(), DeviceEvents, defConfig());
super(em, 'DeviceManager', new Devices([], { em } as any), DeviceEvents, defConfig());
this.devices = this.all;
this.config.devices?.forEach((device) => this.add(device, { silent: true }));
this.select(this.config.default || this.devices.at(0));

6
packages/core/src/device_manager/model/Devices.ts

@ -1,6 +1,10 @@
import { Collection } from '../../common';
import Device from './Device';
export default class Devices extends Collection<Device> {}
export default class Devices extends Collection<Device> {
constructor(models?: any, opts: any = {}) {
super(models, opts);
}
}
Devices.prototype.model = Device;

2
packages/core/src/dom_components/index.ts

@ -364,7 +364,7 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
* @private
*/
constructor(em: EditorModel) {
super(em, 'DomComponents', new Components(undefined, { em }), ComponentsEvents, defConfig());
super(em, 'DomComponents', new Components(undefined, { em, collectionId: 'root' }), ComponentsEvents, defConfig());
const { config } = this;
this.symbols = new Symbols([], { em, config, domc: this });

154
packages/core/src/dom_components/model/Component.ts

@ -1,4 +1,5 @@
import { Model, ModelDestroyOptions } from 'backbone';
import PatchManager, { type PatchPath } from '../../patch_manager';
import {
bindAll,
forEach,
@ -158,6 +159,22 @@ type GetComponentStyleOpts = GetStyleOpts & {
* @module docsjs.Component
*/
export default class Component extends StyleableModel<ComponentProperties> {
patchObjectType = 'components';
protected getPatchExcludedPaths(): PatchPath[] {
return [
['toolbar'],
['traits'],
['status'],
['open'],
['delegate'],
['_undoexc'],
['dataResolverWatchers'],
['attributes', 'class'],
// Structural changes are tracked via `componentsOrder`
['components'],
];
}
/**
* @private
* @ts-ignore */
@ -292,75 +309,84 @@ export default class Component extends StyleableModel<ComponentProperties> {
bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps', 'syncOnComponentChange');
// Propagate properties from parent if indicated
const parent = this.parent();
const parentAttr = parent?.attributes;
const propagate = this.get('propagate');
propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]);
if (parentAttr && parentAttr.propagate && !propagate) {
const newAttr: Partial<ComponentProperties> = {};
const toPropagate = parentAttr.propagate;
toPropagate.forEach((prop) => (newAttr[prop] = parent.get(prop as string)));
newAttr.propagate = toPropagate;
this.set({ ...newAttr, ...props });
}
const pm = em && ((em as any).Patches as PatchManager | undefined);
const init = () => {
// Propagate properties from parent if indicated
const parent = this.parent();
const parentAttr = parent?.attributes;
const propagate = this.get('propagate');
propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]);
if (parentAttr && parentAttr.propagate && !propagate) {
const newAttr: Partial<ComponentProperties> = {};
const toPropagate = parentAttr.propagate;
toPropagate.forEach((prop) => (newAttr[prop] = parent.get(prop as string)));
newAttr.propagate = toPropagate;
this.set({ ...newAttr, ...props });
}
// Check void elements
if (opt && opt.config && opt.config.voidElements!.indexOf(this.get('tagName')!) >= 0) {
this.set('void', true);
}
// Check void elements
if (opt && opt.config && opt.config.voidElements!.indexOf(this.get('tagName')!) >= 0) {
this.set('void', true);
}
opt.em = em;
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt as any);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
this.initToolbar();
this.initScriptProps();
this.listenTo(this, 'change:script', this.scriptUpdated);
this.listenTo(this, 'change:tagName', this.tagUpdated);
this.listenTo(this, 'change:attributes', this.attrUpdated);
this.listenTo(this, 'change:attributes:id', this._idUpdated);
this.on('change:toolbar', this.__emitUpdateTlb);
this.on('change', this.__onChange);
this.on(keyUpdateInside, this.__propToParent);
this.set('status', '');
this.views = [];
// Register global updates for collection properties
['classes', 'traits', 'components'].forEach((name) => {
const events = `add remove reset ${name !== 'components' ? 'change' : ''}`;
this.listenTo(this.get(name), events.trim(), (...args) => this.emitUpdate(name, ...args));
});
opt.em = em;
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt as any);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
this.initToolbar();
this.initScriptProps();
this.listenTo(this, 'change:script', this.scriptUpdated);
this.listenTo(this, 'change:tagName', this.tagUpdated);
this.listenTo(this, 'change:attributes', this.attrUpdated);
this.listenTo(this, 'change:attributes:id', this._idUpdated);
this.on('change:toolbar', this.__emitUpdateTlb);
this.on('change', this.__onChange);
this.on(keyUpdateInside, this.__propToParent);
this.set('status', '');
this.views = [];
// Register global updates for collection properties
['classes', 'traits', 'components'].forEach((name) => {
const events = `add remove reset ${name !== 'components' ? 'change' : ''}`;
this.listenTo(this.get(name), events.trim(), (...args) => this.emitUpdate(name, ...args));
});
if (!opt.temporary) {
// Add component styles
const cssc = em && em.Css;
const { styles, type } = this.attributes;
if (styles && cssc) {
cssc.addCollection(styles, { avoidUpdateStyle: true }, { group: `cmp:${type}` });
if (!opt.temporary) {
// Add component styles
const cssc = em && em.Css;
const { styles, type } = this.attributes;
if (styles && cssc) {
cssc.addCollection(styles, { avoidUpdateStyle: true }, { group: `cmp:${type}` });
}
this._moveInlineStyleToRule();
this.__postAdd();
this.init();
isSymbol(this) && initSymbol(this);
em?.trigger(ComponentsEvents.create, this, opt);
}
this._moveInlineStyleToRule();
this.__postAdd();
this.init();
isSymbol(this) && initSymbol(this);
em?.trigger(ComponentsEvents.create, this, opt);
}
if (avoidInline(em)) {
this.dataResolverWatchers.disableStyles();
}
};
if (avoidInline(em)) {
this.dataResolverWatchers.disableStyles();
if (pm?.isEnabled) {
pm.withSuppressedTracking(init);
} else {
init();
}
}
@ -1017,7 +1043,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
// Have to add components after the init, otherwise the parent
// is not visible
const comps = new Components([], this.opt);
comps.parent = this;
comps.setParent(this);
const components = this.get('components');
const addChild = !this.opt.avoidChildren;
this.set('components', comps);

187
packages/core/src/dom_components/model/Components.ts

@ -1,10 +1,13 @@
import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore';
import Component, { SetAttrOptions } from './Component';
import { AddOptions, Collection } from '../../common';
import { AddOptions } from '../../common';
import { DomComponentsConfig } from '../config/config';
import EditorModel from '../../editor/model/Editor';
import ComponentManager from '..';
import CssRule from '../../css_composer/model/CssRule';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import { generateKeyBetween, generateNKeysBetween } from '../../utils/fractionalIndex';
import { serialize } from '../../utils/mixins';
import {
ComponentAdd,
@ -114,6 +117,7 @@ export interface ComponentsOptions {
em: EditorModel;
config?: DomComponentsConfig;
domc?: ComponentManager;
collectionId?: string;
}
interface AddComponentOptions extends AddOptions {
@ -121,7 +125,23 @@ interface AddComponentOptions extends AddOptions {
keepIds?: string[];
}
export default class Components extends Collection</**
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const isOrderMap = (value: any): value is Record<string, any> =>
value != null && typeof value === 'object' && !Array.isArray(value);
const getOrderKeyByUid = (orderMap: Record<string, any>, uid: string | number) => {
const entries = Object.entries(orderMap);
const match = entries.find(([, value]) => value === uid);
return match ? match[0] : undefined;
};
const TEMP_MOVE_FLAG = '__patchTempMove';
export default class Components extends CollectionWithPatches</**
* Keep this format to avoid errors in TS bundler */
/** @ts-ignore */
Component> {
@ -132,11 +152,13 @@ Component> {
parent?: Component;
constructor(models: any, opt: ComponentsOptions) {
super(models, opt);
super(models, { ...opt, patchObjectType: 'components', trackOrder: false } as any);
this.opt = opt;
this.listenTo(this, 'add', this.onAdd);
this.listenTo(this, 'remove', this.handlePatchRemove);
this.listenTo(this, 'remove', this.removeChildren);
this.listenTo(this, 'reset', this.resetChildren);
this.listenTo(this, 'add', this.handlePatchAdd);
const { em, config } = opt;
this.config = config;
this.em = em;
@ -147,6 +169,165 @@ Component> {
return this.domc?.events!;
}
setParent(parent: Component) {
this.parent = parent;
this.stopListening(parent, 'change:componentsOrder', this.handleComponentsOrderChange);
this.listenTo(parent, 'change:componentsOrder', this.handleComponentsOrderChange);
this.patchManager && this.ensureParentOrderMap();
}
protected handleComponentsOrderChange(_model: Component, value: any, opts: any = {}) {
if (opts.fromComponents) return;
if (!isOrderMap(value)) return;
const ordered = Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, uid]) => uid);
const byUid = new Map<string | number, Component>();
this.models.forEach((model) => {
const uid = (model as any).get?.('uid');
if (isValidPatchUid(uid)) {
byUid.set(uid, model);
}
});
const nextModels: Component[] = [];
ordered.forEach((uid) => {
const model = byUid.get(uid);
model && nextModels.push(model);
});
// Append leftovers (eg. models without uid/order entry) keeping current order.
const included = new Set(nextModels.map((m) => m.cid));
this.models.forEach((model) => {
if (!included.has(model.cid)) {
nextModels.push(model);
}
});
if (!nextModels.length) return;
this.models.splice(0, this.models.length, ...nextModels);
this.trigger('sort', this, { fromPatches: true });
}
protected ensureModelUid(model: Component): string | number | undefined {
const uid = (model as any).get?.('uid');
if (isValidPatchUid(uid)) return uid;
const pm = this.patchManager;
if (!pm) return;
(model as any).set?.({}, { silent: true });
const nextUid = (model as any).get?.('uid');
return isValidPatchUid(nextUid) ? nextUid : undefined;
}
protected ensureParentOrderMap(excludeUid?: string | number): Record<string, any> {
const parent = this.parent;
if (!parent) return {};
const current = parent.get('componentsOrder');
if (isOrderMap(current)) return current;
if (!this.patchManager) return {};
const models = this.models.filter((model) => {
const uid = this.ensureModelUid(model);
return uid !== excludeUid;
});
const uids = models.map((model) => this.ensureModelUid(model)).filter(isValidPatchUid);
const keys = generateNKeysBetween(null, null, uids.length);
const map: Record<string, any> = {};
uids.forEach((uid, index) => {
map[keys[index]] = uid;
});
// Initialize without recording a patch (it's a derived structure).
(parent as any).attributes.componentsOrder = map;
return map;
}
protected handlePatchAdd(model: Component, _collection: any, opts: any = {}) {
const pm = this.patchManager;
const parent = this.parent;
if (!pm || !parent) return;
const uid = this.ensureModelUid(model);
const parentUid = this.ensureModelUid(parent as any);
if (!isValidPatchUid(uid) || !isValidPatchUid(parentUid)) return;
const isTempMove = !!(model as any)[TEMP_MOVE_FLAG];
if (isTempMove) {
delete (model as any)[TEMP_MOVE_FLAG];
}
if (!isTempMove && !opts.fromUndo) {
const patch = pm.createOrGetCurrentPatch();
const attrPrefix = [this.patchObjectType as string, uid, 'attributes'];
const isAttrPatch = (p: any) => {
const { path, from } = p || {};
const startsWith = (value?: any[]) => attrPrefix.every((seg, index) => value?.[index] === seg);
return startsWith(path) || startsWith(from);
};
patch.changes = patch.changes.filter((p: any) => !isAttrPatch(p));
patch.reverseChanges = patch.reverseChanges.filter((p: any) => !isAttrPatch(p));
patch.changes.push({
op: 'add',
path: [this.patchObjectType as string, uid],
value: { attributes: serialize(model.toJSON()) },
});
patch.reverseChanges.unshift({ op: 'remove', path: [this.patchObjectType as string, uid] });
}
const baseMap = this.ensureParentOrderMap(uid);
const cleanMap = Object.fromEntries(Object.entries(baseMap).filter(([, value]) => value !== uid));
const index = this.indexOf(model);
const prevModel = index > 0 ? this.at(index - 1) : undefined;
const nextModel = index < this.length - 1 ? this.at(index + 1) : undefined;
const prevUid = prevModel ? this.ensureModelUid(prevModel) : undefined;
const nextUid = nextModel ? this.ensureModelUid(nextModel) : undefined;
const prevKey = prevUid != null ? getOrderKeyByUid(cleanMap, prevUid) : undefined;
const nextKey = nextUid != null ? getOrderKeyByUid(cleanMap, nextUid) : undefined;
const newKey = generateKeyBetween(prevKey ?? null, nextKey ?? null);
const nextMap = { ...cleanMap, [newKey]: uid };
parent.set('componentsOrder', nextMap, { ...opts, fromComponents: true });
}
protected handlePatchRemove(model: Component, _collection: any, opts: any = {}) {
const pm = this.patchManager;
const parent = this.parent;
if (!pm || !parent) return;
const uid = this.ensureModelUid(model);
const parentUid = this.ensureModelUid(parent as any);
if (!isValidPatchUid(uid) || !isValidPatchUid(parentUid)) return;
if (opts.temporary) {
(model as any)[TEMP_MOVE_FLAG] = true;
}
const currentMap = this.ensureParentOrderMap();
const orderKey = isOrderMap(currentMap) ? getOrderKeyByUid(currentMap, uid) : undefined;
if (orderKey) {
const { [orderKey]: _removed, ...rest } = currentMap;
parent.set('componentsOrder', rest, { ...opts, fromComponents: true });
}
const isTemp = opts.temporary || opts.fromUndo;
if (isTemp) return;
const patch = pm.createOrGetCurrentPatch();
patch.changes.push({ op: 'remove', path: [this.patchObjectType as string, uid] });
patch.reverseChanges.unshift({
op: 'add',
path: [this.patchObjectType as string, uid],
value: { attributes: serialize(model.toJSON()) },
});
}
resetChildren(models: Components, opts: { previousModels?: Component[]; keepIds?: string[] } = {}) {
const coll = this;
const prev = opts.previousModels || [];

25
packages/core/src/domain_abstract/model/StyleableModel.ts

@ -1,8 +1,8 @@
import { isArray, isObject, isString, keys } from 'underscore';
import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common';
import { ObjectAny, ObjectHash, SetOptions } from '../../common';
import ParserHtml from '../../parser/model/ParserHtml';
import Selectors from '../../selector_manager/model/Selectors';
import { shallowDiff } from '../../utils/mixins';
import { createId, shallowDiff } from '../../utils/mixins';
import EditorModel from '../../editor/model/Editor';
import CssRuleView from '../../css_composer/view/CssRuleView';
import ComponentView from '../../dom_components/view/ComponentView';
@ -13,6 +13,7 @@ import { DataCollectionStateMap } from '../../data_sources/model/data_collection
import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWatcher';
import { DataResolverProps } from '../../data_sources/types';
import { _StringKey } from 'backbone';
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
export type StyleProps = Record<string, string | string[] | DataResolverProps>;
@ -44,7 +45,20 @@ type WithDataResolvers<T> = {
[P in keyof T]?: T[P] | DataResolverProps;
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends Model<T, UpdateStyleOptions> {
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const createStableUid = () => {
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID;
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId();
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends ModelWithPatches<
T,
UpdateStyleOptions
> {
em?: EditorModel;
views: StyleableView[] = [];
dataResolverWatchers: ModelDataResolverWatchers<T>;
@ -312,6 +326,11 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
const mergedProps = { ...props, ...attributes };
const mergedOpts = { ...this.opt, ...opts };
const uid = (mergedProps as any).uid;
if (isValidPatchUid(uid)) {
(mergedProps as any).uid = createStableUid();
}
const ClassConstructor = this.constructor as new (attributes: any, opts?: any) => typeof this;
return new ClassConstructor(mergedProps, mergedOpts);

14
packages/core/src/editor/config/config.ts

@ -306,6 +306,17 @@ export interface EditorConfig {
*/
undoManager?: UndoManagerConfig | boolean;
/**
* Patch manager options (experimental).
*/
patches?: {
/**
* Enable patch tracking.
* @default false
*/
enable?: boolean;
};
/**
* Configurations for Asset Manager.
*/
@ -486,6 +497,9 @@ const config: () => EditorConfig = () => ({
},
i18n: {},
undoManager: {},
patches: {
enable: false,
},
assetManager: {},
canvas: {},
layerManager: {},

4
packages/core/src/editor/index.ts

@ -77,6 +77,7 @@ import TraitManager from '../trait_manager';
import UndoManagerModule from '../undo_manager';
import UtilsModule from '../utils';
import html from '../utils/html';
import PatchManager from '../patch_manager';
import defConfig, { EditorConfig, EditorConfigKeys } from './config/config';
import EditorModel, { EditorLoadOptions } from './model/Editor';
import {
@ -152,6 +153,9 @@ export default class Editor implements IBaseModule<EditorConfig> {
get UndoManager(): UndoManagerModule {
return this.em.UndoManager;
}
get Patches(): PatchManager {
return this.em.Patches;
}
get RichTextEditor(): RichTextEditorModule {
return this.em.RichTextEditor;
}

12
packages/core/src/editor/model/Editor.ts

@ -46,6 +46,7 @@ import DataSourceManager from '../../data_sources';
import { ComponentsEvents } from '../../dom_components/types';
import { InitEditorConfig } from '../..';
import { EditorEvents, SelectComponentOptions } from '../types';
import PatchManager from '../../patch_manager';
Backbone.$ = $;
@ -178,6 +179,10 @@ export default class EditorModel extends Model {
return this.get('UndoManager');
}
get Patches(): PatchManager {
return this.get('Patches');
}
get RichTextEditor(): RichTextEditorModule {
return this.get('RichTextEditor');
}
@ -252,6 +257,13 @@ export default class EditorModel extends Model {
this.set('storables', []);
this.set('selected', new Selected());
this.set('dmode', config.dragMode);
this.set(
'Patches',
new PatchManager({
enabled: !!config.patches?.enable,
emitter: this,
}),
);
const { el, log } = config;
const toLog = log === true ? keys(logs) : isArray(log) ? log : [];
bindAll(this, 'initBaseColorPicker');

27
packages/core/src/editor/types.ts

@ -12,8 +12,9 @@ import { SelectorEvent } from '../selector_manager';
import { StyleManagerEvent } from '../style_manager';
import { EditorConfig } from './config/config';
import EditorModel from './model/Editor';
import { PatchProps } from '../patch_manager';
type GeneralEvent = 'canvasScroll' | 'undo' | 'redo' | 'load' | 'update';
type GeneralEvent = 'canvasScroll' | 'undo' | 'redo' | 'load' | 'update' | 'patch:update' | 'patch:undo' | 'patch:redo';
type EditorBuiltInEvents =
| DataSourceEvent
@ -37,6 +38,9 @@ export type EditorConfigType = EditorConfig & { pStylePrefix?: string };
export type EditorModelParam<T extends keyof EditorModel, N extends number> = Parameters<EditorModel[T]>[N];
export interface EditorEventCallbacks extends AssetsEventCallback, BlocksEventCallback, DataSourcesEventCallback {
'patch:update': [PatchProps];
'patch:undo': [PatchProps];
'patch:redo': [PatchProps];
[key: string]: any[];
}
@ -68,6 +72,27 @@ export enum EditorEvents {
*/
redo = 'redo',
/**
* @event `patch:update` Patch finalized.
* @example
* editor.on('patch:update', (patch) => { ... });
*/
patchUpdate = 'patch:update',
/**
* @event `patch:undo` Patch undo executed.
* @example
* editor.on('patch:undo', (patch) => { ... });
*/
patchUndo = 'patch:undo',
/**
* @event `patch:redo` Patch redo executed.
* @example
* editor.on('patch:redo', (patch) => { ... });
*/
patchRedo = 'patch:redo',
/**
* @event `load` Editor is loaded. At this stage, the project is loaded in the editor and elements in the canvas are rendered.
* @example

2
packages/core/src/pages/index.ts

@ -73,7 +73,7 @@ export default class PageManager extends ItemManagerModule<PageManagerConfig, Pa
* @param {Object} config Configurations
*/
constructor(em: EditorModel) {
super(em, 'PageManager', new Pages([], em), PagesEvents);
super(em, 'PageManager', new Pages([], { em } as any), PagesEvents);
bindAll(this, '_onPageChange');
const model = new ModuleModel(this, { _undo: true });
this.model = model;

4
packages/core/src/pages/model/Page.ts

@ -6,6 +6,7 @@ import ComponentWrapper from '../../dom_components/model/ComponentWrapper';
import EditorModel from '../../editor/model/Editor';
import { CssRuleJSON } from '../../css_composer/model/CssRule';
import { ComponentDefinition } from '../../dom_components/model/types';
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
/** @private */
export interface PageProperties {
@ -37,7 +38,8 @@ export interface PagePropertiesDefined extends Pick<PageProperties, 'id' | 'name
[key: string]: unknown;
}
export default class Page extends Model<PagePropertiesDefined> {
export default class Page extends ModelWithPatches<PagePropertiesDefined> {
patchObjectType = 'page';
defaults() {
return {
name: '',

12
packages/core/src/pages/model/Pages.ts

@ -1,10 +1,14 @@
import { Collection, RemoveOptions } from '../../common';
import { RemoveOptions } from '../../common';
import EditorModel from '../../editor/model/Editor';
import Page from './Page';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
export default class Pages extends Collection<Page> {
constructor(models: any, em: EditorModel) {
super(models);
export default class Pages extends CollectionWithPatches<Page> {
patchObjectType = 'pages';
constructor(models: any, opts: { em: EditorModel; collectionId?: string }) {
const { em } = opts;
super(models, { ...opts, patchObjectType: 'pages', collectionId: opts.collectionId || 'global' } as any);
this.on('reset', this.onReset);
this.on('remove', this.onRemove);

323
packages/core/src/patch_manager/CollectionWithPatches.ts

@ -0,0 +1,323 @@
import { generateNKeysBetween } from '../utils/fractionalIndex';
import { AddOptions, Collection, Model } from '../common';
import EditorModel from '../editor/model/Editor';
import PatchManager, { PatchChangeProps, PatchPath } from './index';
export interface CollectionWithPatchesOptions extends AddOptions {
em?: EditorModel;
collectionId?: string;
patchObjectType?: string;
trackOrder?: boolean;
}
export type FractionalEntry<T extends Model = Model> = {
id: string;
key: string;
model?: T | undefined;
};
type PendingRemoval = {
oldKey: string;
patch: any;
change: PatchChangeProps;
reverse: PatchChangeProps;
};
export default class CollectionWithPatches<T extends Model = Model> extends Collection<T> {
em?: EditorModel;
collectionId?: string;
patchObjectType?: string;
private fractionalMap: Record<string, string> = {};
private pendingRemovals: Record<string, PendingRemoval> = {};
private suppressSortRebuild = false;
private isResetting = false;
private trackOrder = true;
constructor(models?: any, options: CollectionWithPatchesOptions = {}) {
const nextOptions = { ...options };
super(models, nextOptions);
this.em = nextOptions.em;
this.collectionId = nextOptions.collectionId;
this.patchObjectType = nextOptions.patchObjectType;
this.trackOrder = nextOptions.trackOrder !== false;
if (this.trackOrder) {
this.on('sort', this.handleSort, this);
this.rebuildFractionalMap(false);
}
// Ensure tracking/registry works for apply(external) in enabled mode.
Promise.resolve().then(() => {
const pm = this.patchManager;
const id = this.getPatchCollectionId();
if (pm?.isEnabled && this.patchObjectType && id != null) {
pm.trackCollection?.(this as any);
}
});
}
get patchManager(): PatchManager | undefined {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
return pm?.isEnabled && this.patchObjectType ? pm : undefined;
}
setCollectionId(id: string) {
this.collectionId = id;
}
add(model: T | {}, options?: CollectionWithPatchesOptions): T;
add(models: Array<T | {}>, options?: CollectionWithPatchesOptions): T[];
add(models: any, options?: CollectionWithPatchesOptions): any {
const result = super.add(models, this.withEmOptions(options) as any);
this.trackOrder && !this.isResetting && this.assignKeysForMissingModels();
return result as any;
}
remove(model: T | {}, options?: any): T;
remove(models: Array<T | {}>, options?: any): T[];
remove(models: any, options?: any): any {
const removed = super.remove(models, options as any);
if (!this.trackOrder) return removed;
const removedModels = Array.isArray(removed) ? removed : removed ? [removed] : [];
removedModels.forEach((model) => {
const id = this.getModelId(model as any);
if (!id) return;
const oldKey = this.fractionalMap[id];
if (oldKey == null) return;
delete this.fractionalMap[id];
const pending = this.recordFractionalPatch(id, undefined, oldKey);
if (pending) {
this.pendingRemovals[id] = pending;
Promise.resolve().then(() => {
// Cleanup in case it was not re-added in the same tick.
if (this.pendingRemovals[id]) {
delete this.pendingRemovals[id];
}
});
}
});
return removed;
}
reset(models?: any, options?: CollectionWithPatchesOptions) {
this.isResetting = true;
try {
const result = super.reset(models, this.withEmOptions(options) as any);
if (this.trackOrder) {
this.fractionalMap = {};
this.pendingRemovals = {};
this.rebuildFractionalMap();
}
return result;
} finally {
this.isResetting = false;
}
}
protected handleSort(_collection?: any, options: any = {}) {
if (!this.trackOrder) return;
if (this.suppressSortRebuild || options?.fromPatches) return;
this.rebuildFractionalMap();
}
protected getPatchCollectionId(): string | undefined {
return this.collectionId;
}
protected withEmOptions(options?: CollectionWithPatchesOptions) {
const nextOptions = options ? { ...options } : {};
if (this.em && nextOptions.em == null) {
nextOptions.em = this.em;
}
return nextOptions;
}
protected rebuildFractionalMap(record: boolean = true) {
if (!this.trackOrder) return;
const ids = this.models.map((model) => this.getModelId(model)).filter(Boolean);
const keys = ids.length ? generateNKeysBetween(null, null, ids.length) : [];
const prevMap = { ...this.fractionalMap };
const nextMap: Record<string, string> = {};
ids.forEach((id, index) => {
const key = keys[index];
nextMap[id] = key;
if (record) {
this.recordFractionalPatch(id, key, prevMap[id]);
}
});
if (record) {
Object.keys(prevMap).forEach((id) => {
if (!(id in nextMap)) {
this.recordFractionalPatch(id, undefined, prevMap[id]);
}
});
}
this.fractionalMap = nextMap;
}
protected assignKeysForMissingModels() {
if (!this.trackOrder) return;
let idx = 0;
const models = this.models;
while (idx < models.length) {
const model = models[idx];
const id = this.getModelId(model);
if (!id || this.fractionalMap[id]) {
idx++;
continue;
}
const segmentIds: string[] = [];
const segmentStartIdx = idx;
while (idx < models.length) {
const segId = this.getModelId(models[idx]);
if (!segId || this.fractionalMap[segId]) break;
segmentIds.push(segId);
idx++;
}
// Find previous and next keys around the segment, based on current collection order.
let prevKey: string | null = null;
for (let i = segmentStartIdx - 1; i >= 0; i--) {
const prevId = this.getModelId(models[i]);
if (prevId && this.fractionalMap[prevId]) {
prevKey = this.fractionalMap[prevId];
break;
}
}
let nextKey: string | null = null;
for (let i = idx; i < models.length; i++) {
const nextId = this.getModelId(models[i]);
if (nextId && this.fractionalMap[nextId]) {
nextKey = this.fractionalMap[nextId];
break;
}
}
const keys = generateNKeysBetween(prevKey, nextKey, segmentIds.length);
segmentIds.forEach((segId, i) => {
const newKey = keys[i];
this.fractionalMap[segId] = newKey;
const pending = this.pendingRemovals[segId];
if (pending) {
this.removeRecordedPatch(pending);
delete this.pendingRemovals[segId];
this.recordFractionalPatch(segId, newKey, pending.oldKey);
} else {
this.recordFractionalPatch(segId, newKey, undefined);
}
});
}
}
protected getModelId(model: T): string {
if (!model) return '';
if (typeof (model as any).getId === 'function') {
const id = (model as any).getId();
const valid = typeof id === 'string' ? id !== '' : typeof id === 'number';
return valid ? String(id) : '';
}
const id = (model as any).get?.('id');
return (id as string) || model.cid || '';
}
protected recordFractionalPatch(id: string, newKey?: string, oldKey?: string): PendingRemoval | void {
const pm = this.patchManager;
const objectType = this.patchObjectType;
const collectionId = this.getPatchCollectionId();
if (!pm || !pm.isEnabled || !objectType || !collectionId) return;
if (newKey === oldKey) return;
const path: PatchPath = [objectType, collectionId, 'order', id];
let change: PatchChangeProps;
let reverse: PatchChangeProps;
if (newKey === undefined) {
change = { op: 'remove', path };
reverse = { op: 'add', path, value: oldKey };
} else if (oldKey === undefined) {
change = { op: 'add', path, value: newKey };
reverse = { op: 'remove', path };
} else {
change = { op: 'replace', path, value: newKey };
reverse = { op: 'replace', path, value: oldKey };
}
const patch = pm.createOrGetCurrentPatch();
patch.changes.push(change);
// Reverse changes should be applied in reverse order.
patch.reverseChanges.unshift(reverse);
if (newKey === undefined && oldKey != null) {
return { oldKey, patch, change, reverse };
}
}
getAndSortFractionalMap(): FractionalEntry<T>[] {
return Object.entries(this.fractionalMap)
.sort(([idA, keyA], [idB, keyB]) => keyA.localeCompare(keyB) || idA.localeCompare(idB))
.map(([id, key]) => ({ id, key, model: this.getModelByPatchId(id) }));
}
getOrderKey(id: string) {
return this.fractionalMap[id];
}
applyOrderKeyPatch(id: string, op: PatchChangeProps['op'], value?: string) {
if (!id) return;
if (op === 'remove') {
delete this.fractionalMap[id];
const model = this.getModelByPatchId(id);
model && Collection.prototype.remove.call(this, model as any);
return;
}
if (op === 'add' || op === 'replace') {
if (value == null) return;
this.fractionalMap[id] = value;
this.sortByFractionalOrder();
}
}
protected sortByFractionalOrder() {
const entries = this.getAndSortFractionalMap();
const sorted = entries.map((e) => e.model).filter(Boolean) as T[];
if (!sorted.length) return;
const included = new Set(sorted.map((m) => m.cid));
const leftovers = this.models.filter((m) => !included.has(m.cid));
const nextModels = [...sorted, ...leftovers];
this.suppressSortRebuild = true;
try {
this.models.splice(0, this.models.length, ...nextModels);
this.trigger('sort', this, { fromPatches: true });
} finally {
this.suppressSortRebuild = false;
}
}
private removeRecordedPatch(pending: PendingRemoval) {
const patch = pending.patch;
const changeIdx = patch?.changes?.indexOf?.(pending.change);
if (changeIdx >= 0) patch.changes.splice(changeIdx, 1);
const reverseIdx = patch?.reverseChanges?.indexOf?.(pending.reverse);
if (reverseIdx >= 0) patch.reverseChanges.splice(reverseIdx, 1);
}
private getModelByPatchId(id: string): T | undefined {
return this.models.find((model) => this.getModelId(model) === id);
}
}

216
packages/core/src/patch_manager/ModelWithPatches.ts

@ -0,0 +1,216 @@
import { enablePatches, produceWithPatches } from 'immer';
import EditorModel from '../editor/model/Editor';
import { Model, ObjectHash, SetOptions } from '../common';
import { createId, serialize } from '../utils/mixins';
import PatchManager, { PatchChangeProps, PatchPath } from './index';
enablePatches();
type SetArgs<T> = {
attrs: Partial<T>;
opts: SetOptions;
};
const normalizeSetArgs = <T>(args: any[]): SetArgs<T> => {
const [first, second, third] = args;
if (typeof first === 'string') {
return {
attrs: { [first]: second } as any,
opts: (third || {}) as SetOptions,
};
}
return {
attrs: (first || {}) as Partial<T>,
opts: (second || {}) as SetOptions,
};
};
const normalizePatchPaths = (patches: PatchChangeProps[], prefix: PatchPath): PatchChangeProps[] =>
patches.map((patch) => ({
...patch,
path: [...prefix, ...patch.path],
...(patch.from ? { from: [...prefix, ...patch.from] } : {}),
}));
const syncDraftToState = (draft: any, target: any) => {
const isObject = (value: any): value is Record<string, any> =>
value != null && typeof value === 'object' && !Array.isArray(value);
if (Array.isArray(draft) && Array.isArray(target)) {
if (draft.length > target.length) {
draft.splice(target.length, draft.length - target.length);
}
for (let i = 0; i < target.length; i++) {
const draftValue = draft[i];
const targetValue = target[i];
if (Array.isArray(draftValue) && Array.isArray(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (isObject(draftValue) && isObject(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (draftValue !== targetValue) {
draft[i] = targetValue;
}
}
// Add new entries (after syncing shared indexes).
for (let i = draft.length; i < target.length; i++) {
draft.push(target[i]);
}
return;
}
if (!isObject(draft) || !isObject(target)) {
return;
}
Object.keys(draft).forEach((key) => {
if (!(key in target)) {
delete draft[key];
}
});
Object.keys(target).forEach((key) => {
const draftValue = draft[key];
const targetValue = target[key];
if (Array.isArray(draftValue) && Array.isArray(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (isObject(draftValue) && isObject(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (draftValue !== targetValue) {
draft[key] = targetValue;
}
});
};
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const createStableUid = () => {
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID;
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId();
};
const stripUid = <T extends ObjectHash>(attrs: Partial<T>): Partial<T> => {
const attrsAny = attrs as any;
if (attrsAny && typeof attrsAny === 'object' && 'uid' in attrsAny) {
const { uid: _uid, ...rest } = attrsAny;
return rest as Partial<T>;
}
return attrs;
};
const isPatchPathExcluded = (path: PatchPath, exclusions: PatchPath[]) =>
exclusions.some((excludedPath) => excludedPath.every((excludedSeg, index) => path[index] === excludedSeg));
const filterExcludedPatches = (patches: PatchChangeProps[], exclusions: PatchPath[]) => {
if (!exclusions.length || !patches.length) return patches;
return patches.filter((patch) => {
const { path, from } = patch;
if (isPatchPathExcluded(path, exclusions)) return false;
if (from && isPatchPathExcluded(from, exclusions)) return false;
return true;
});
};
export default class ModelWithPatches<T extends ObjectHash = any, S = SetOptions, E = any> extends Model<T, S, E> {
em?: EditorModel;
patchObjectType?: string;
constructor(attributes?: T, options: any = {}) {
super(attributes as any, options);
options?.em && (this.em = options.em);
Promise.resolve().then(() => {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
if (pm?.isEnabled && this.patchObjectType) {
pm.trackModel(this as any);
}
});
}
protected getPatchExcludedPaths(): PatchPath[] {
return [];
}
protected get patchManager(): PatchManager | undefined {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
if (pm?.isEnabled && this.patchObjectType) {
pm.trackModel(this as any);
return pm;
}
return undefined;
}
protected getPatchObjectId(): string | number | undefined {
return this.get('uid' as any);
}
clone(): this {
const attrs = serialize(this.attributes || {}) as any;
attrs.uid = createStableUid();
return new (this.constructor as any)(attrs);
}
set(...args: any[]): this {
const { attrs: rawAttrs, opts } = normalizeSetArgs<T>(args);
const existingUid = this.get('uid' as any) as string | number | undefined;
const hasExistingUid = isValidPatchUid(existingUid);
// UID is immutable: ignore any attempt to change/unset it via public `set`
const immutableAttrs = hasExistingUid ? stripUid(rawAttrs) : rawAttrs;
const pm = this.patchManager;
if (!pm) {
return super.set(immutableAttrs as any, opts as any);
}
// Never accept UID mutations via public `set` while tracking patches
const attrsNoUid = stripUid(immutableAttrs);
const beforeState = serialize(this.attributes || {}) as any;
const stateUid = beforeState.uid;
const uid = isValidPatchUid(stateUid) ? stateUid : hasExistingUid ? existingUid : pm.createId();
beforeState.uid = uid;
// Ensure UID exists before applying changes, but do not record it in patches
if (!hasExistingUid && isValidPatchUid(uid)) {
super.set({ uid } as any, { silent: true } as any);
}
if (!isValidPatchUid(uid)) {
return super.set(attrsNoUid as any, opts as any);
}
const result = super.set(attrsNoUid as any, opts as any);
const afterState = serialize(this.attributes || {});
(afterState as any).uid = uid;
const [, patches, inversePatches] = produceWithPatches<any>(beforeState, (draft: any) => {
syncDraftToState(draft, afterState);
});
const excludedPaths = this.getPatchExcludedPaths();
const nextPatches = filterExcludedPatches(patches, excludedPaths);
const nextInversePatches = filterExcludedPatches(inversePatches, excludedPaths);
if (nextPatches.length || nextInversePatches.length) {
const prefix: PatchPath = [this.patchObjectType as string, uid, 'attributes'];
const activePatch = pm.createOrGetCurrentPatch();
activePatch.changes.push(...normalizePatchPaths(nextPatches, prefix));
// Reverse changes should be applied in reverse order.
activePatch.reverseChanges.unshift(...normalizePatchPaths(nextInversePatches, prefix));
}
return result;
}
}

352
packages/core/src/patch_manager/index.ts

@ -0,0 +1,352 @@
import { createId, serialize } from '../utils/mixins';
import { applyPatches } from 'immer';
export type PatchOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
export type PatchPath = Array<string | number>;
export type PatchChangeProps = {
op: PatchOp;
path: PatchPath;
value?: any;
from?: PatchPath;
};
export type PatchProps = {
id: string;
changes: PatchChangeProps[];
reverseChanges: PatchChangeProps[];
};
export type PatchApplyOptions = {
external?: boolean;
direction?: 'forward' | 'backward';
};
export type PatchApplyHandler = (changes: PatchChangeProps[], options?: PatchApplyOptions) => void;
export type PatchEventEmitter = {
trigger: (event: string, payload?: any) => void;
};
export type PatchManagerOptions = {
enabled?: boolean;
emitter?: PatchEventEmitter;
applyPatch?: PatchApplyHandler;
};
export const PatchManagerEvents = {
update: 'patch:update',
undo: 'patch:undo',
redo: 'patch:redo',
} as const;
type InternalPatch = PatchProps & { recordable: boolean };
const createPatchId = () => {
// Prefer UUID when available, fallback to legacy id generator
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID;
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId();
};
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
export default class PatchManager {
isEnabled: boolean;
private emitter?: PatchEventEmitter;
private applyHandler?: PatchApplyHandler;
private history: PatchProps[] = [];
private redoStack: PatchProps[] = [];
private activePatch?: InternalPatch;
private updateDepth = 0;
private finalizeScheduled = false;
private suppressTracking = false;
private trackedModels: Record<string, Record<string, any>> = {};
private trackedCollections: Record<string, Record<string, any>> = {};
constructor(options: PatchManagerOptions = {}) {
this.isEnabled = !!options.enabled;
this.emitter = options.emitter;
this.applyHandler = options.applyPatch;
}
trackModel(model: any): void {
if (!model) return;
const type = model.patchObjectType;
const id =
typeof model.getPatchObjectId === 'function' ? model.getPatchObjectId() : (model.get?.('uid') ?? model.uid);
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedModels[type] = this.trackedModels[type] || {};
this.trackedModels[type][idStr] = model;
}
untrackModel(model: any): void {
if (!model) return;
const type = model.patchObjectType;
const id =
typeof model.getPatchObjectId === 'function' ? model.getPatchObjectId() : (model.get?.('uid') ?? model.uid);
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedModels[type] && delete this.trackedModels[type][idStr];
}
trackCollection(collection: any): void {
if (!collection) return;
const type = collection.patchObjectType;
const id =
typeof collection.getPatchCollectionId === 'function'
? collection.getPatchCollectionId()
: collection.collectionId;
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedCollections[type] = this.trackedCollections[type] || {};
this.trackedCollections[type][idStr] = collection;
}
untrackCollection(collection: any): void {
if (!collection) return;
const type = collection.patchObjectType;
const id =
typeof collection.getPatchCollectionId === 'function'
? collection.getPatchCollectionId()
: collection.collectionId;
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedCollections[type] && delete this.trackedCollections[type][idStr];
}
createId(): string {
return createPatchId();
}
createOrGetCurrentPatch(): PatchProps {
if (!this.shouldRecord()) {
return this.createVoidPatch();
}
if (!this.activePatch) {
this.activePatch = this.createPatch();
if (!this.updateDepth) {
this.scheduleFinalize();
}
}
return this.activePatch;
}
finalizeCurrentPatch(): void {
const patch = this.activePatch;
this.activePatch = undefined;
this.finalizeScheduled = false;
if (!patch || !patch.recordable) return;
if (!patch.changes.length && !patch.reverseChanges.length) return;
this.add(patch);
}
update(cb: () => void): void {
if (!this.isEnabled) {
cb();
return;
}
this.updateDepth++;
this.createOrGetCurrentPatch();
try {
cb();
} finally {
this.updateDepth--;
if (this.updateDepth === 0) {
this.finalizeCurrentPatch();
}
}
}
add(patch: PatchProps): void {
if (!this.shouldRecord()) return;
this.history.push(patch);
this.redoStack = [];
this.emit(PatchManagerEvents.update, patch);
}
apply(patch: PatchProps, opts: { external?: boolean } = {}): void {
if (!this.isEnabled) return;
const { external = false } = opts;
const addToHistory = !external;
if (addToHistory) {
this.finalizeCurrentPatch();
}
this.applyChanges(patch.changes, { external, direction: 'forward' });
if (addToHistory) {
this.history.push(patch);
this.redoStack = [];
this.emit(PatchManagerEvents.update, patch);
}
}
undo(): PatchProps | undefined {
if (!this.isEnabled) return;
this.finalizeCurrentPatch();
const patch = this.history.pop();
if (!patch) return;
this.applyChanges(patch.reverseChanges, { direction: 'backward' });
this.redoStack.push(patch);
this.emit(PatchManagerEvents.undo, patch);
return patch;
}
redo(): PatchProps | undefined {
if (!this.isEnabled) return;
this.finalizeCurrentPatch();
const patch = this.redoStack.pop();
if (!patch) return;
this.applyChanges(patch.changes, { direction: 'forward' });
this.history.push(patch);
this.emit(PatchManagerEvents.redo, patch);
return patch;
}
private applyChanges(changes: PatchChangeProps[], options: PatchApplyOptions = {}) {
if (!changes.length) return;
this.withSuppressedTracking(() => {
if (this.applyHandler) {
this.applyHandler(changes, options);
} else {
this.applyTrackedChanges(changes);
}
});
}
private applyTrackedChanges(changes: PatchChangeProps[]) {
const modelGroups = new Map<string, { type: string; id: string; patches: PatchChangeProps[] }>();
changes.forEach((change) => {
const path = change.path || [];
if (path.length < 3) return;
const type = String(path[0]);
const targetId = String(path[1]);
const scope = String(path[2]);
if (scope === 'attributes') {
const groupKey = `${type}::${targetId}`;
const group = modelGroups.get(groupKey) || { type, id: targetId, patches: [] };
group.patches.push(change);
modelGroups.set(groupKey, group);
return;
}
if (scope === 'order') {
const modelId = path[3] != null ? String(path[3]) : '';
const coll = this.trackedCollections[type]?.[targetId];
if (coll && typeof coll.applyOrderKeyPatch === 'function') {
coll.applyOrderKeyPatch(modelId, change.op, change.value);
}
}
});
modelGroups.forEach(({ type, id, patches }) => {
const model = this.trackedModels[type]?.[id];
if (!model || typeof model.set !== 'function') return;
const current = serialize(model.attributes || {});
const localPatches = patches.map((p) => ({
...p,
path: (p.path || []).slice(3),
...(p.from ? { from: (p.from || []).slice(3) } : {}),
})) as any;
const next = applyPatches(current, localPatches);
const toSet: any = {};
const toUnset: string[] = [];
Object.keys(next).forEach((key) => {
if (current[key] !== next[key]) {
toSet[key] = next[key];
}
});
Object.keys(current).forEach((key) => {
if (!(key in next)) {
toUnset.push(key);
}
});
Object.keys(toSet).length && model.set(toSet);
toUnset.forEach((key) => model.unset?.(key));
});
}
withSuppressedTracking<T>(cb: () => T): T {
const prevSuppress = this.suppressTracking;
this.suppressTracking = true;
try {
return cb();
} finally {
this.suppressTracking = prevSuppress;
}
}
private shouldRecord() {
return this.isEnabled && !this.suppressTracking;
}
private createPatch(): InternalPatch {
return {
id: createPatchId(),
changes: [],
reverseChanges: [],
recordable: true,
};
}
private createVoidPatch(): InternalPatch {
return {
id: '',
changes: [],
reverseChanges: [],
recordable: false,
};
}
private scheduleFinalize() {
if (this.updateDepth || this.finalizeScheduled) return;
this.finalizeScheduled = true;
Promise.resolve().then(() => {
this.finalizeScheduled = false;
if (!this.updateDepth) {
this.finalizeCurrentPatch();
}
});
}
private emit(event: string, payload: PatchProps): void {
this.emitter?.trigger?.(event, payload);
}
}
export { default as CollectionWithPatches } from './CollectionWithPatches';
export { PatchObjectsRegistry, createRegistryApplyPatchHandler, type PatchUid } from './registry';

90
packages/core/src/patch_manager/registry.ts

@ -0,0 +1,90 @@
import { applyPatches } from 'immer';
import { serialize } from '../utils/mixins';
import type { PatchApplyHandler, PatchApplyOptions, PatchChangeProps, PatchPath } from './index';
export type PatchUid = string | number;
export class PatchObjectsRegistry<T = any> {
private byType: Record<string, Map<PatchUid, T>> = {};
register(type: string, uid: PatchUid, obj: T): void {
if (!this.byType[type]) {
this.byType[type] = new Map();
}
this.byType[type].set(uid, obj);
}
unregister(type: string, uid: PatchUid): void {
this.byType[type]?.delete(uid);
}
get(type: string, uid: PatchUid): T | undefined {
return this.byType[type]?.get(uid);
}
clear(type?: string): void {
if (type) {
delete this.byType[type];
return;
}
this.byType = {};
}
}
type PatchGroup = {
type: string;
uid: PatchUid;
patches: PatchChangeProps[];
};
const getPatchGroupKey = (type: string, uid: PatchUid) => `${type}::${uid}`;
const stripPrefix = (path: PatchPath, prefixLen: number): PatchPath => path.slice(prefixLen);
const normalizeForApply = (patch: PatchChangeProps): PatchChangeProps => {
const prefixLen = 3; // [type, uid, 'attributes', ...]
return {
...patch,
path: stripPrefix(patch.path, prefixLen),
...(patch.from ? { from: stripPrefix(patch.from, prefixLen) } : {}),
};
};
const syncModelToState = (model: any, state: any, options?: PatchApplyOptions) => {
const current = model.attributes || {};
Object.keys(current).forEach((key) => {
if (!(key in state)) {
model.unset(key, options as any);
}
});
model.set(state, options as any);
};
export const createRegistryApplyPatchHandler = (registry: PatchObjectsRegistry): PatchApplyHandler => {
return (changes: PatchChangeProps[], options?: PatchApplyOptions) => {
const groups = new Map<string, PatchGroup>();
changes.forEach((patch) => {
const [type, uid, scope] = patch.path;
if (typeof type !== 'string' || (typeof uid !== 'string' && typeof uid !== 'number')) return;
if (scope !== 'attributes') return;
const key = getPatchGroupKey(type, uid);
const group = groups.get(key) || { type, uid, patches: [] };
group.patches.push(patch);
groups.set(key, group);
});
groups.forEach(({ type, uid, patches }) => {
const model = registry.get(type, uid);
if (!model) return;
const baseState = serialize(model.attributes || {});
const nextState = applyPatches(baseState, patches.map(normalizeForApply) as any);
syncModelToState(model, nextState, options);
});
};
};

8
packages/core/src/selector_manager/index.ts

@ -106,14 +106,16 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
*/
constructor(em: EditorModel) {
super(em, 'SelectorManager', new Selectors([]), SelectorEvents, defConfig(), { skipListen: true });
super(em, 'SelectorManager', new Selectors([], { em, collectionId: 'all' } as any), SelectorEvents, defConfig(), {
skipListen: true,
});
bindAll(this, '__updateSelectedByComponents');
const { config, events } = this;
const ppfx = config.pStylePrefix;
if (ppfx) config.stylePrefix = ppfx + config.stylePrefix;
this.all = new Selectors(config.selectors);
this.selected = new Selectors([], { em, config });
this.all = new Selectors(config.selectors, { em, config, collectionId: 'all' } as any);
this.selected = new Selectors([], { em, config, collectionId: 'selected' } as any);
this.states = new Collection<State>(
config.states!.map((state: any) => new State(state)),
{ model: State },

4
packages/core/src/selector_manager/model/Selector.ts

@ -2,6 +2,7 @@ import { result, forEach, keys } from 'underscore';
import { Model } from '../../common';
import EditorModel from '../../editor/model/Editor';
import { SelectorManagerConfig } from '../config/config';
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
const TYPE_CLASS = 1;
const TYPE_ID = 2;
@ -33,7 +34,8 @@ export interface SelectorOptions {
* @property {Boolean} [private=false] If true, it can't be seen by the Style Manager, but it will be rendered in the canvas and in export code.
* @property {Boolean} [protected=false] If true, it can't be removed from the attached component.
*/
export default class Selector extends Model<SelectorPropsCustom> {
export default class Selector extends ModelWithPatches<SelectorPropsCustom> {
patchObjectType = 'selector';
defaults() {
return {
name: '',

10
packages/core/src/selector_manager/model/Selectors.ts

@ -1,5 +1,5 @@
import { filter } from 'underscore';
import { Collection } from '../../common';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import Selector from './Selector';
const combine = (tail: string[], curr: string): string[] => {
@ -16,7 +16,13 @@ export interface FullNameOptions {
array?: boolean;
}
export default class Selectors extends Collection<Selector> {
export default class Selectors extends CollectionWithPatches<Selector> {
patchObjectType = 'selectors';
constructor(models?: any, opts: any = {}) {
super(models, { ...opts, patchObjectType: 'selectors', collectionId: opts.collectionId } as any);
}
modelId(attr: any) {
return `${attr.name}_${attr.type || Selector.TYPE_CLASS}`;
}

2
packages/core/src/trait_manager/model/Trait.ts

@ -1,6 +1,6 @@
import { isString, isUndefined } from 'underscore';
import Category from '../../abstract/ModuleCategory';
import { LocaleOptions, Model, SetOptions } from '../../common';
import { LocaleOptions, SetOptions, Model } from '../../common';
import Component from '../../dom_components/model/Component';
import EditorModel from '../../editor/model/Editor';
import { isDef } from '../../utils/mixins';

6
packages/core/src/trait_manager/model/Traits.ts

@ -15,8 +15,8 @@ export default class Traits extends CollectionWithCategories<Trait> {
tf: TraitFactory;
categories = new Categories();
constructor(coll: TraitProperties[], options: { em: EditorModel }) {
super(coll);
constructor(coll: TraitProperties[], options: { em: EditorModel; collectionId?: string }) {
super(coll, options as any);
const { em } = options;
this.em = em;
this.categories = new Categories([], {
@ -55,6 +55,8 @@ export default class Traits extends CollectionWithCategories<Trait> {
setTarget(target: Component) {
this.target = target;
const id = (typeof (target as any).getId === 'function' && (target as any).getId()) || target.cid;
id && this.setCollectionId(id);
this.models.forEach((trait) => trait.setTarget(target));
}

222
packages/core/src/utils/fractionalIndex.ts

@ -0,0 +1,222 @@
// Based on rocicorp/fractional-indexing (CC0)
// https://github.com/rocicorp/fractional-indexing
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function midpoint(a: string, b: string | null | undefined, digits: string): string {
const zero = digits[0];
if (b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
throw new Error('trailing zero');
}
if (b) {
let n = 0;
while ((a[n] || zero) === b[n]) {
n++;
}
if (n > 0) {
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
}
}
const digitA = a ? digits.indexOf(a[0]) : 0;
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
if (digitB - digitA > 1) {
const midDigit = Math.round(0.5 * (digitA + digitB));
return digits[midDigit];
} else {
if (b && b.length > 1) {
return b.slice(0, 1);
} else {
return digits[digitA] + midpoint(a.slice(1), null, digits);
}
}
}
function getIntegerLength(head: string): number {
if (head >= 'a' && head <= 'z') {
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
} else if (head >= 'A' && head <= 'Z') {
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
}
throw new Error(`invalid order key head: ${head}`);
}
function validateInteger(int: string): void {
if (int.length !== getIntegerLength(int[0])) {
throw new Error(`invalid integer part of order key: ${int}`);
}
}
function getIntegerPart(key: string): string {
const integerPartLength = getIntegerLength(key[0]);
if (integerPartLength > key.length) {
throw new Error(`invalid order key: ${key}`);
}
return key.slice(0, integerPartLength);
}
function validateOrderKey(key: string, digits: string): void {
if (key === `A${digits[0].repeat(26)}`) {
throw new Error(`invalid order key: ${key}`);
}
const i = getIntegerPart(key);
const f = key.slice(i.length);
if (f.slice(-1) === digits[0]) {
throw new Error(`invalid order key: ${key}`);
}
}
function incrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split('');
let carry = true;
for (let i = digs.length - 1; carry && i >= 0; i--) {
const d = digits.indexOf(digs[i]) + 1;
if (d === digits.length) {
digs[i] = digits[0];
} else {
digs[i] = digits[d];
carry = false;
}
}
if (carry) {
if (head === 'Z') {
return `a${digits[0]}`;
}
if (head === 'z') {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) + 1);
if (h > 'a') {
digs.push(digits[0]);
} else {
digs.pop();
}
return h + digs.join('');
}
return head + digs.join('');
}
function decrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split('');
let borrow = true;
for (let i = digs.length - 1; borrow && i >= 0; i--) {
const d = digits.indexOf(digs[i]) - 1;
if (d === -1) {
digs[i] = digits.slice(-1);
} else {
digs[i] = digits[d];
borrow = false;
}
}
if (borrow) {
if (head === 'a') {
return `Z${digits.slice(-1)}`;
}
if (head === 'A') {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) - 1);
if (h < 'Z') {
digs.push(digits.slice(-1));
} else {
digs.pop();
}
return h + digs.join('');
}
return head + digs.join('');
}
export function generateKeyBetween(
a: string | null | undefined,
b: string | null | undefined,
digits = BASE_62_DIGITS,
): string {
if (a != null) {
validateOrderKey(a, digits);
}
if (b != null) {
validateOrderKey(b, digits);
}
if (a != null && b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a == null) {
if (b == null) {
return `a${digits[0]}`;
}
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ib === `A${digits[0].repeat(26)}`) {
return ib + midpoint('', fb, digits);
}
if (ib < b) {
return ib;
}
const res = decrementInteger(ib, digits);
if (res == null) {
throw new Error('cannot decrement any more');
}
return res;
}
if (b == null) {
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const i = incrementInteger(ia, digits);
return i == null ? `${ia}${midpoint(fa, null, digits)}` : i;
}
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ia === ib) {
return `${ia}${midpoint(fa, fb, digits)}`;
}
const i = incrementInteger(ia, digits);
if (i == null) {
throw new Error('cannot increment any more');
}
if (i < b) {
return i;
}
return `${ia}${midpoint(fa, null, digits)}`;
}
export function generateNKeysBetween(
a: string | null | undefined,
b: string | null | undefined,
n: number,
digits = BASE_62_DIGITS,
): string[] {
if (n === 0) {
return [];
}
if (n === 1) {
return [generateKeyBetween(a, b, digits)];
}
if (b == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(c, b, digits);
result.push(c);
}
return result;
}
if (a == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(a, c, digits);
result.push(c);
}
result.reverse();
return result;
}
const mid = Math.floor(n / 2);
const c = generateKeyBetween(a, b, digits);
return [...generateNKeysBetween(a, c, mid, digits), c, ...generateNKeysBetween(c, b, n - mid - 1, digits)];
}

204
packages/core/test/specs/patch_manager/collection/CollectionWithPatches.js

@ -0,0 +1,204 @@
import PatchManager from 'patch_manager';
import CollectionWithPatches from 'patch_manager/CollectionWithPatches';
import { Model } from 'common';
class TestModel extends Model {
getId() {
return this.get('id');
}
}
class TestCollection extends CollectionWithPatches {
patchObjectType = 'test-collection';
}
describe('CollectionWithPatches', () => {
test('records order changes and sorts models after inserts', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const em = { Patches: pm };
const coll = new TestCollection([], { em, collectionId: 'root' });
coll.add(new TestModel({ id: 'a' }));
coll.add(new TestModel({ id: 'b' }));
coll.add(new TestModel({ id: 'c' }), { at: 1 });
await Promise.resolve();
await Promise.resolve();
const sortedIds = coll.getAndSortFractionalMap().map((entry) => entry.id);
expect(sortedIds).toEqual(['a', 'c', 'b']);
const updateEvents = events.filter((item) => item.event === 'patch:update');
expect(updateEvents).toHaveLength(1);
const payload = updateEvents[updateEvents.length - 1].payload;
const prefix = ['test-collection', 'root'];
const matchesPrefix = payload.changes.every((change) =>
prefix.every((segment, index) => change.path[index] === segment),
);
expect(matchesPrefix).toBe(true);
});
test('move within the same collection generates replace and supports undo/redo', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const em = { Patches: pm };
const coll = new TestCollection([], { em, collectionId: 'root' });
coll.add(new TestModel({ id: 'a' }));
coll.add(new TestModel({ id: 'b' }));
coll.add(new TestModel({ id: 'c' }));
await Promise.resolve();
await Promise.resolve();
events.length = 0;
const modelC = coll.get('c');
coll.remove(modelC);
coll.add(modelC, { at: 1 });
await Promise.resolve();
await Promise.resolve();
const movedIds = coll.getAndSortFractionalMap().map((entry) => entry.id);
expect(movedIds).toEqual(['a', 'c', 'b']);
const updateEvents = events.filter((item) => item.event === 'patch:update');
expect(updateEvents).toHaveLength(1);
const patch = updateEvents[0].payload;
const moveChanges = patch.changes.filter((c) => c.path[3] === 'c');
expect(moveChanges).toHaveLength(1);
expect(moveChanges[0].op).toBe('replace');
pm.undo();
const undoIds = coll.getAndSortFractionalMap().map((entry) => entry.id);
expect(undoIds).toEqual(['a', 'b', 'c']);
pm.redo();
const redoIds = coll.getAndSortFractionalMap().map((entry) => entry.id);
expect(redoIds).toEqual(['a', 'c', 'b']);
});
test('apply(external) applies order patches without re-logging', async () => {
const pmAEvents = [];
const pmA = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => pmAEvents.push({ event, payload }) },
});
const pmBEvents = [];
const pmB = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => pmBEvents.push({ event, payload }) },
});
const emA = { Patches: pmA };
const emB = { Patches: pmB };
const collA = new TestCollection([], { em: emA, collectionId: 'root' });
const collB = new TestCollection([], { em: emB, collectionId: 'root' });
['a', 'b', 'c'].forEach((id) => {
collA.add(new TestModel({ id }));
collB.add(new TestModel({ id }));
});
await Promise.resolve();
await Promise.resolve();
pmAEvents.length = 0;
pmBEvents.length = 0;
// Produce a patch on A
const modelC = collA.get('c');
collA.remove(modelC);
collA.add(modelC, { at: 1 });
await Promise.resolve();
await Promise.resolve();
const patch = pmAEvents.find((e) => e.event === 'patch:update')?.payload;
expect(patch).toBeTruthy();
// Apply patch to B as external (no patch:update expected)
pmB.apply(patch, { external: true });
const idsB = collB.getAndSortFractionalMap().map((entry) => entry.id);
expect(idsB).toEqual(['a', 'c', 'b']);
expect(pmBEvents).toHaveLength(0);
});
test('fractional order is deterministic under key collisions (concurrent ops)', async () => {
const pm = new PatchManager({ enabled: true });
const em = { Patches: pm };
const coll = new TestCollection([], { em, collectionId: 'root' });
['a', 'b', 'c', 'd'].forEach((id) => coll.add(new TestModel({ id })));
await Promise.resolve();
await Promise.resolve();
const conflictKey = coll.getOrderKey('b');
expect(conflictKey).toBeTruthy();
const patch1 = {
id: 'p1',
changes: [{ op: 'replace', path: ['test-collection', 'root', 'order', 'c'], value: conflictKey }],
reverseChanges: [],
};
const patch2 = {
id: 'p2',
changes: [{ op: 'replace', path: ['test-collection', 'root', 'order', 'd'], value: conflictKey }],
reverseChanges: [],
};
pm.apply(patch1, { external: true });
pm.apply(patch2, { external: true });
const ids1 = coll.getAndSortFractionalMap().map((e) => e.id);
// Reset and apply in reverse order
const coll2 = new TestCollection([], { em, collectionId: 'root-2' });
['a', 'b', 'c', 'd'].forEach((id) => coll2.add(new TestModel({ id })));
await Promise.resolve();
await Promise.resolve();
pm.trackCollection(coll2);
const patch1b = {
...patch1,
changes: [{ ...patch1.changes[0], path: ['test-collection', 'root-2', 'order', 'c'] }],
};
const patch2b = {
...patch2,
changes: [{ ...patch2.changes[0], path: ['test-collection', 'root-2', 'order', 'd'] }],
};
pm.apply(patch2b, { external: true });
pm.apply(patch1b, { external: true });
const ids2 = coll2.getAndSortFractionalMap().map((e) => e.id);
expect(ids2).toEqual(ids1);
});
test('skips patch recording when disabled', async () => {
const events = [];
const pm = new PatchManager({
enabled: false,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const em = { Patches: pm };
const coll = new TestCollection([], { em, collectionId: 'root' });
coll.add(new TestModel({ id: 'x' }));
await Promise.resolve();
expect(events).toHaveLength(0);
});
});

252
packages/core/test/specs/patch_manager/components.js

@ -0,0 +1,252 @@
import { applyPatches } from 'immer';
import Component from 'dom_components/model/Component';
import Editor from 'editor/model/Editor';
import PatchManager from 'patch_manager';
import { generateKeyBetween, generateNKeysBetween } from 'utils/fractionalIndex';
import { serialize } from 'utils/mixins';
const flush = () => Promise.resolve();
const getUpdatePatches = (events) => events.filter((e) => e.event === 'patch:update').map((e) => e.payload);
const initState = (models) => ({
components: Object.fromEntries(
models.map((model) => {
const attributes = serialize(model.toJSON());
delete attributes.components;
return [model.get('uid'), { attributes }];
}),
),
});
describe('Patch tracking: nested Components order', () => {
let em;
let compOpts;
beforeEach(() => {
em = new Editor({ avoidDefaults: true, avoidInlineStyle: true });
em.Pages.onLoad();
const domc = em.Components;
compOpts = { em, componentTypes: domc.componentTypes, domc };
});
afterEach(() => {
em.destroyAll();
});
test('Does not create patches for non-storable props (toolbar/traits/status)', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
const cmp = new Component({}, compOpts);
em.set('Patches', pm);
events.length = 0;
cmp.set('toolbar', [{ command: 'tlb-move' }]);
cmp.set('traits', [{ type: 'text', name: 'title' }]);
cmp.set('status', 'selected');
await flush();
expect(getUpdatePatches(events)).toHaveLength(0);
});
test('Add child: records component add + order-map add patches', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
pm.createId = () => 'child-1';
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
em.set('Patches', pm);
parent.components().setParent(parent);
events.length = 0;
parent.append({ tagName: 'div' });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
expect(patch.changes[0]).toMatchObject({
op: 'add',
path: ['components', 'child-1'],
});
expect(patch.changes[0].value.attributes.uid).toBe('child-1');
expect(patch.changes[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)],
value: 'child-1',
});
// Undo order must remove map entry first, then the component object.
expect(patch.reverseChanges[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)],
});
expect(patch.reverseChanges[1]).toEqual({
op: 'remove',
path: ['components', 'child-1'],
});
});
test('Remove child: records order-map remove + component remove patches', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
pm.createId = () => 'child-1';
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
em.set('Patches', pm);
parent.components().setParent(parent);
const [child] = parent.append({ tagName: 'div' });
await flush();
events.length = 0;
parent.components().remove(child);
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
const orderKey = generateKeyBetween(null, null);
expect(patch.changes[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey],
});
expect(patch.changes[1]).toEqual({
op: 'remove',
path: ['components', 'child-1'],
});
// Undo order must re-add the component object first, then restore the order map.
expect(patch.reverseChanges[0]).toMatchObject({
op: 'add',
path: ['components', 'child-1'],
});
expect(patch.reverseChanges[0].value.attributes.uid).toBe('child-1');
expect(patch.reverseChanges[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey],
value: 'child-1',
});
});
test('Reorder within same parent updates only componentsOrder (no array index moves)', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
const [c1, c2, c3] = parent.append([{ tagName: 'div' }, { tagName: 'span' }, { tagName: 'p' }]);
c1.set('uid', 'c1');
c2.set('uid', 'c2');
c3.set('uid', 'c3');
em.set('Patches', pm);
parent.components().setParent(parent);
events.length = 0;
// Move c1 to the end (temporary remove + re-add).
parent.components().remove(c1, { temporary: true });
parent.components().add(c1, { at: 2 });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
const [k1, k2, k3] = generateNKeysBetween(null, null, 3);
const newKey = generateKeyBetween(k3, null);
expect(patch.changes[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', k1],
});
expect(patch.changes[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', newKey],
value: 'c1',
});
// Regression: no patches for `attributes.components` array indices.
const hasComponentsArrayPatch = patch.changes.some((ch) => {
const p = ch.path || [];
for (let i = 0; i < p.length - 1; i++) {
if (p[i] === 'attributes' && p[i + 1] === 'components') return true;
}
return false;
});
expect(hasComponentsArrayPatch).toBe(false);
});
test('Move between parents updates order maps and is undo/redo deterministic', async () => {
const events = [];
let state;
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
applyPatch: (changes) => {
state = applyPatches(state, changes);
},
});
const parentA = new Component({}, compOpts);
const parentB = new Component({}, compOpts);
parentA.set('uid', 'parentA');
parentB.set('uid', 'parentB');
const [child] = parentA.append({ tagName: 'div' });
child.set('uid', 'c1');
em.set('Patches', pm);
parentA.components().setParent(parentA);
parentB.components().setParent(parentB);
state = initState([parentA, parentB, child]);
const before = JSON.parse(JSON.stringify(state));
events.length = 0;
parentA.components().remove(child, { temporary: true });
parentB.components().add(child, { at: 0 });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
state = applyPatches(state, patch.changes);
const after = JSON.parse(JSON.stringify(state));
pm.undo();
expect(state).toEqual(before);
pm.redo();
expect(state).toEqual(after);
});
});

92
packages/core/test/specs/patch_manager/index.js

@ -0,0 +1,92 @@
import PatchManager, { PatchManagerEvents } from 'patch_manager';
describe('PatchManager', () => {
test('Records a patch during update and emits update event', () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
pm.update(() => {
const patch = pm.createOrGetCurrentPatch();
patch.changes.push({ op: 'replace', path: ['value'], value: 1 });
patch.reverseChanges.push({ op: 'replace', path: ['value'], value: 0 });
});
expect(events).toHaveLength(1);
expect(events[0].event).toBe(PatchManagerEvents.update);
expect(events[0].payload.changes).toHaveLength(1);
expect(events[0].payload.reverseChanges).toHaveLength(1);
});
test('Applies patches and respects the external flag', () => {
const calls = [];
const events = [];
const pm = new PatchManager({
enabled: true,
applyPatch: (changes, options) => calls.push({ changes, options }),
emitter: {
trigger: (event) => events.push(event),
},
});
const patch = {
id: 'patch-1',
changes: [{ op: 'add', path: ['value'], value: 1 }],
reverseChanges: [{ op: 'remove', path: ['value'] }],
};
pm.apply(patch);
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
changes: patch.changes,
options: { external: false, direction: 'forward' },
});
expect(events).toEqual([PatchManagerEvents.update]);
calls.length = 0;
events.length = 0;
pm.apply(patch, { external: true });
expect(calls).toHaveLength(1);
expect(calls[0]).toEqual({
changes: patch.changes,
options: { external: true, direction: 'forward' },
});
expect(events).toHaveLength(0);
});
test('Undo and redo apply reverse/forward changes', () => {
const calls = [];
const events = [];
const pm = new PatchManager({
enabled: true,
applyPatch: (changes, options) => calls.push({ changes, options }),
emitter: {
trigger: (event) => events.push(event),
},
});
const patch = {
id: 'patch-2',
changes: [{ op: 'replace', path: ['value'], value: 2 }],
reverseChanges: [{ op: 'replace', path: ['value'], value: 1 }],
};
pm.add(patch);
const undoPatch = pm.undo();
const redoPatch = pm.redo();
expect(undoPatch).toBe(patch);
expect(redoPatch).toBe(patch);
expect(calls[0]).toEqual({ changes: patch.reverseChanges, options: { direction: 'backward' } });
expect(calls[1]).toEqual({ changes: patch.changes, options: { direction: 'forward' } });
expect(events).toEqual([PatchManagerEvents.update, PatchManagerEvents.undo, PatchManagerEvents.redo]);
});
});

146
packages/core/test/specs/patch_manager/model/ModelWithPatches.js

@ -0,0 +1,146 @@
import PatchManager, { PatchManagerEvents } from 'patch_manager';
import ModelWithPatches from 'patch_manager/ModelWithPatches';
describe('ModelWithPatches', () => {
test('set records patch with normalized path', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
pm.createId = () => 'uid-1';
const model = new ModelWithPatches({ id: 'model-1', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
model.set('foo', 'baz');
await Promise.resolve();
expect(events).toHaveLength(1);
expect(events[0].event).toBe(PatchManagerEvents.update);
const patch = events[0].payload;
expect(model.get('uid')).toBe('uid-1');
expect(patch.changes).toHaveLength(1);
expect(patch.reverseChanges).toHaveLength(1);
expect(patch.changes[0]).toMatchObject({
op: 'replace',
path: ['model', 'uid-1', 'attributes', 'foo'],
value: 'baz',
});
expect(patch.reverseChanges[0]).toMatchObject({
op: 'replace',
path: ['model', 'uid-1', 'attributes', 'foo'],
value: 'bar',
});
});
test('set skips patch recording without a patch object type', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const model = new ModelWithPatches({ id: 'model-2', foo: 'bar' });
model.em = { Patches: pm };
model.set('foo', 'baz');
await Promise.resolve();
expect(model.get('foo')).toBe('baz');
expect(events).toHaveLength(0);
});
test('apply handler changes do not create patches while tracking is suppressed', async () => {
const events = [];
let model;
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
applyPatch: () => {
model.set('foo', 'applied');
},
});
model = new ModelWithPatches({ uid: 'uid-3', id: 'model-3', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
pm.apply(
{
id: 'patch-3',
changes: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'applied' }],
reverseChanges: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'bar' }],
},
{ external: true },
);
await Promise.resolve();
expect(model.get('foo')).toBe('applied');
expect(events).toHaveLength(0);
});
test('apply(external) updates tracked model without custom applyPatch', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const model = new ModelWithPatches({ uid: 'uid-4', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
pm.trackModel(model);
pm.apply(
{
id: 'patch-4',
changes: [{ op: 'replace', path: ['model', 'uid-4', 'attributes', 'foo'], value: 'baz' }],
reverseChanges: [{ op: 'replace', path: ['model', 'uid-4', 'attributes', 'foo'], value: 'bar' }],
},
{ external: true },
);
expect(model.get('foo')).toBe('baz');
await Promise.resolve();
expect(events).toHaveLength(0);
});
test('uid is immutable once set', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const model = new ModelWithPatches({ uid: 'uid-4', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
model.set('uid', 'uid-changed');
await Promise.resolve();
expect(model.get('uid')).toBe('uid-4');
expect(events).toHaveLength(0);
});
});

32
packages/core/test/specs/patch_manager/registry.js

@ -0,0 +1,32 @@
import PatchManager, { PatchObjectsRegistry, createRegistryApplyPatchHandler } from 'patch_manager';
import ModelWithPatches from 'patch_manager/ModelWithPatches';
describe('PatchObjectsRegistry', () => {
test('apply handler resolves models by uid and applies forward/backward changes', () => {
const registry = new PatchObjectsRegistry();
const pm = new PatchManager({
enabled: true,
applyPatch: createRegistryApplyPatchHandler(registry),
});
const model = new ModelWithPatches({ uid: 'uid-1', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
registry.register('model', 'uid-1', model);
const patch = {
id: 'patch-1',
changes: [{ op: 'replace', path: ['model', 'uid-1', 'attributes', 'foo'], value: 'baz' }],
reverseChanges: [{ op: 'replace', path: ['model', 'uid-1', 'attributes', 'foo'], value: 'bar' }],
};
pm.apply(patch);
expect(model.get('foo')).toBe('baz');
pm.undo();
expect(model.get('foo')).toBe('bar');
pm.redo();
expect(model.get('foo')).toBe('baz');
});
});

8
pnpm-lock.yaml

@ -238,6 +238,9 @@ importers:
html-entities:
specifier: ~1.4.0
version: 1.4.0
immer:
specifier: ^10.1.1
version: 10.2.0
promise-polyfill:
specifier: 8.3.0
version: 8.3.0
@ -4731,6 +4734,9 @@ packages:
immediate@3.3.0:
resolution: {integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
@ -14438,6 +14444,8 @@ snapshots:
immediate@3.3.0: {}
immer@10.2.0: {}
immutable@4.3.7: {}
import-cwd@2.1.0:

Loading…
Cancel
Save