Browse Source

add pm to any modules

pull/6691/head
IhorKaleniuk666 7 months ago
parent
commit
b9d3486994
  1. 5
      packages/core/src/abstract/CollectionWithCategories.ts
  2. 8
      packages/core/src/asset_manager/index.ts
  3. 5
      packages/core/src/asset_manager/model/Asset.ts
  4. 4
      packages/core/src/asset_manager/model/Assets.ts
  5. 4
      packages/core/src/block_manager/index.ts
  6. 5
      packages/core/src/block_manager/model/Block.ts
  7. 3
      packages/core/src/block_manager/model/Blocks.ts
  8. 2
      packages/core/src/css_composer/index.ts
  9. 1
      packages/core/src/css_composer/model/CssRule.ts
  10. 13
      packages/core/src/css_composer/model/CssRules.ts
  11. 2
      packages/core/src/device_manager/index.ts
  12. 5
      packages/core/src/device_manager/model/Device.ts
  13. 10
      packages/core/src/device_manager/model/Devices.ts
  14. 8
      packages/core/src/dom_components/index.ts
  15. 2
      packages/core/src/dom_components/model/Component.ts
  16. 8
      packages/core/src/dom_components/model/Components.ts
  17. 5
      packages/core/src/domain_abstract/model/StyleableModel.ts
  18. 14
      packages/core/src/editor/config/config.ts
  19. 4
      packages/core/src/editor/index.ts
  20. 12
      packages/core/src/editor/model/Editor.ts
  21. 27
      packages/core/src/editor/types.ts
  22. 2
      packages/core/src/pages/index.ts
  23. 4
      packages/core/src/pages/model/Page.ts
  24. 12
      packages/core/src/pages/model/Pages.ts
  25. 305
      packages/core/src/patch_manager/CollectionWithPatches.ts
  26. 24
      packages/core/src/patch_manager/ModelWithPatches.ts
  27. 125
      packages/core/src/patch_manager/index.ts
  28. 13
      packages/core/src/selector_manager/index.ts
  29. 4
      packages/core/src/selector_manager/model/Selector.ts
  30. 10
      packages/core/src/selector_manager/model/Selectors.ts
  31. 6
      packages/core/src/trait_manager/model/Trait.ts
  32. 7
      packages/core/src/trait_manager/model/Traits.ts
  33. 226
      packages/core/src/utils/fractionalIndex.ts
  34. 198
      packages/core/test/specs/patch_manager/collection/CollectionWithPatches.js
  35. 33
      packages/core/test/specs/patch_manager/model/ModelWithPatches.js

5
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,7 @@ 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');
}

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

@ -1,10 +1,10 @@
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);
const TypeableCollectionExt = CollectionWithPatches.extend(TypeableCollection);
export default class Assets extends TypeableCollectionExt<Asset> {}

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

5
packages/core/src/block_manager/model/Block.ts

@ -1,4 +1,4 @@
import { Model } from '../../common';
import ModelWithPatches from 'patch_manager/ModelWithPatches';
import { isFunction } from 'underscore';
import Editor from '../../editor';
import Category, { CategoryProperties } from '../../abstract/ModuleCategory';
@ -74,7 +74,8 @@ export interface BlockProperties extends DraggableContent {
*
* @module docsjs.Block
*/
export default class Block extends Model<BlockProperties> {
export default class Block extends ModelWithPatches<BlockProperties> {
patchObjectType = 'block';
defaults() {
return {
label: '',

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

@ -4,9 +4,10 @@ import Block from './Block';
export default class Blocks extends CollectionWithCategories<Block> {
em: EditorModel;
patchObjectType = 'blocks';
constructor(coll: any[], options: { em: EditorModel }) {
super(coll);
super(coll, { ...options, patchObjectType: 'blocks', collectionId: 'global' } as any);
this.em = options.em;
this.on('add', this.handleAdd);
}

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

5
packages/core/src/device_manager/model/Device.ts

@ -1,4 +1,4 @@
import { Model } from '../../common';
import ModelWithPatches from 'patch_manager/ModelWithPatches';
/** @private */
export interface DeviceProperties {
@ -43,7 +43,8 @@ export interface DeviceProperties {
* @property {String} [widthMedia=''] The width which will be used in media queries, If empty the width will be used
* @property {Number} [priority=null] Setup the order of media queries
*/
export default class Device extends Model<DeviceProperties> {
export default class Device extends ModelWithPatches<DeviceProperties> {
patchObjectType = 'device';
defaults() {
return {
name: '',

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

@ -1,6 +1,12 @@
import { Collection } from '../../common';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import Device from './Device';
export default class Devices extends Collection<Device> {}
export default class Devices extends CollectionWithPatches<Device> {
patchObjectType = 'devices';
constructor(models?: any, opts: any = {}) {
super(models, { ...opts, patchObjectType: 'devices', collectionId: opts.collectionId || 'global' } as any);
}
}
Devices.prototype.model = Device;

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

@ -364,7 +364,13 @@ 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 });

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

@ -158,6 +158,7 @@ type GetComponentStyleOpts = GetStyleOpts & {
* @module docsjs.Component
*/
export default class Component extends StyleableModel<ComponentProperties> {
patchObjectType = 'component';
/**
* @private
* @ts-ignore */
@ -1018,6 +1019,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
// is not visible
const comps = new Components([], this.opt);
comps.parent = this;
comps.setCollectionId(this.getId() || this.cid);
const components = this.get('components');
const addChild = !this.opt.avoidChildren;
this.set('components', comps);

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

@ -1,10 +1,11 @@
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 {
ComponentAdd,
@ -114,6 +115,7 @@ export interface ComponentsOptions {
em: EditorModel;
config?: DomComponentsConfig;
domc?: ComponentManager;
collectionId?: string;
}
interface AddComponentOptions extends AddOptions {
@ -121,7 +123,7 @@ interface AddComponentOptions extends AddOptions {
keepIds?: string[];
}
export default class Components extends Collection</**
export default class Components extends CollectionWithPatches</**
* Keep this format to avoid errors in TS bundler */
/** @ts-ignore */
Component> {
@ -132,7 +134,7 @@ Component> {
parent?: Component;
constructor(models: any, opt: ComponentsOptions) {
super(models, opt);
super(models, { ...opt, patchObjectType: 'components', collectionId: opt.collectionId });
this.opt = opt;
this.listenTo(this, 'add', this.onAdd);
this.listenTo(this, 'remove', this.removeChildren);

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

@ -1,5 +1,5 @@
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';
@ -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,7 @@ type WithDataResolvers<T> = {
[P in keyof T]?: T[P] | DataResolverProps;
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends Model<T, UpdateStyleOptions> {
export default class StyleableModel<T extends StyleableModelProperties = any> extends ModelWithPatches<T, UpdateStyleOptions> {
em?: EditorModel;
views: StyleableView[] = [];
dataResolverWatchers: ModelDataResolverWatchers<T>;

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

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

@ -0,0 +1,305 @@
import { generateNKeysBetween } from '../utils/fractionalIndex';
import { Collection, Model, AddOptions } 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;
}
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;
constructor(models?: any, options: CollectionWithPatchesOptions = {}) {
super(models, options);
this.em = options.em;
this.collectionId = options.collectionId;
this.patchObjectType = options.patchObjectType;
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;
if (pm?.isEnabled) {
pm.trackCollection?.(this as any);
}
});
}
// Ensure models created via collection.add/reset get a reference to `em`.
// This is critical for patch tracking and for apply(external) routing.
// @ts-ignore
_prepareModel(attrs: any, options: any) {
const nextOptions = options ? { ...options } : {};
this.em && nextOptions.em == null && (nextOptions.em = this.em);
// @ts-ignore
return Collection.prototype._prepareModel.call(this, attrs, nextOptions);
}
get patchManager(): PatchManager | undefined {
return this.em?.Patches;
}
setCollectionId(id: string) {
this.collectionId = id;
}
add(models: any, options?: CollectionWithPatchesOptions) {
const result = super.add(models, options);
!this.isResetting && this.assignKeysForMissingModels();
return result;
}
remove(...args: any[]) {
const removed = super.remove(...args);
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, options);
this.fractionalMap = {};
this.pendingRemovals = {};
this.rebuildFractionalMap();
return result;
} finally {
this.isResetting = false;
}
}
protected handleSort(_collection?: any, options: any = {}) {
if (this.suppressSortRebuild || options?.fromPatches) return;
this.rebuildFractionalMap();
}
protected getPatchCollectionId(): string | undefined {
return this.collectionId || this.cid;
}
protected rebuildFractionalMap(record: boolean = true) {
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() {
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);
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);
}
}

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

@ -50,12 +50,34 @@ export default class ModelWithPatches<T extends ObjectHash = any, S = SetOptions
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 get patchManager(): PatchManager | undefined {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
return pm?.isEnabled && this.patchObjectType ? pm : undefined;
if (pm?.isEnabled && this.patchObjectType) {
pm.trackModel(this as any);
return pm;
}
return undefined;
}
protected getPatchObjectId(): string | number | undefined {
const withGetId = this as any;
if (typeof withGetId.getId === 'function') {
const stableId = withGetId.getId();
const valid = typeof stableId === 'string' ? stableId !== '' : typeof stableId === 'number';
if (valid) return stableId;
}
const id = (this as any).id ?? (this as any).get?.('id');
return id ?? (this as any).cid;
}

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

@ -1,4 +1,5 @@
import { createId } from '../utils/mixins';
import { createId, serialize } from '../utils/mixins';
import { applyPatches } from 'immer';
export type PatchOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
@ -58,6 +59,8 @@ export default class PatchManager {
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;
@ -65,6 +68,58 @@ export default class PatchManager {
this.applyHandler = options.applyPatch;
}
trackModel(model: any): void {
if (!model) return;
const type = model.patchObjectType;
const idFromGetId = typeof model.getId === 'function' ? model.getId() : undefined;
const hasGetId = typeof idFromGetId === 'string' ? idFromGetId !== '' : typeof idFromGetId === 'number';
const id = hasGetId ? idFromGetId : model.id ?? model.get?.('id') ?? model.cid;
if (!type || id == null) 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 idFromGetId = typeof model.getId === 'function' ? model.getId() : undefined;
const hasGetId = typeof idFromGetId === 'string' ? idFromGetId !== '' : typeof idFromGetId === 'number';
const id = hasGetId ? idFromGetId : model.id ?? model.get?.('id') ?? model.cid;
if (!type || id == null) 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 idFromGetter =
typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : undefined;
const hasGetterId = typeof idFromGetter === 'string' ? idFromGetter !== '' : typeof idFromGetter === 'number';
const id = hasGetterId
? idFromGetter
: collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid;
if (!type || id == null) 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 idFromGetter =
typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : undefined;
const hasGetterId = typeof idFromGetter === 'string' ? idFromGetter !== '' : typeof idFromGetter === 'number';
const id = hasGetterId
? idFromGetter
: collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid;
if (!type || id == null) return;
const idStr = String(id);
this.trackedCollections[type] && delete this.trackedCollections[type][idStr];
}
createOrGetCurrentPatch(): PatchProps {
if (!this.shouldRecord()) {
return this.createVoidPatch();
@ -169,10 +224,73 @@ export default class PatchManager {
}
private applyChanges(changes: PatchChangeProps[], options: PatchApplyOptions = {}) {
if (!changes.length || !this.applyHandler) return;
if (!changes.length) return;
this.withSuppressedTracking(() => {
this.applyHandler?.(changes, options);
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));
});
}
@ -226,3 +344,4 @@ export default class PatchManager {
this.emitter?.trigger?.(event, payload);
}
}
export { default as CollectionWithPatches } from './CollectionWithPatches';

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

@ -106,14 +106,21 @@ 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}`;
}

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

@ -1,12 +1,13 @@
import { isString, isUndefined } from 'underscore';
import Category from '../../abstract/ModuleCategory';
import { LocaleOptions, Model, SetOptions } from '../../common';
import { LocaleOptions, SetOptions } from '../../common';
import Component from '../../dom_components/model/Component';
import EditorModel from '../../editor/model/Editor';
import { isDef } from '../../utils/mixins';
import TraitsEvents, { TraitGetValueOptions, TraitOption, TraitProperties, TraitSetValueOptions } from '../types';
import TraitView from '../view/TraitView';
import Traits from './Traits';
import ModelWithPatches from 'patch_manager/ModelWithPatches';
/**
* @property {String} id Trait id, eg. `my-trait-id`.
@ -21,7 +22,8 @@ import Traits from './Traits';
* @module docsjs.Trait
*
*/
export default class Trait extends Model<TraitProperties> {
export default class Trait extends ModelWithPatches<TraitProperties> {
patchObjectType = 'trait';
target!: Component;
em: EditorModel;
view?: TraitView;

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

@ -14,9 +14,10 @@ export default class Traits extends CollectionWithCategories<Trait> {
target!: Component;
tf: TraitFactory;
categories = new Categories();
patchObjectType = 'traits';
constructor(coll: TraitProperties[], options: { em: EditorModel }) {
super(coll);
constructor(coll: TraitProperties[], options: { em: EditorModel; collectionId?: string }) {
super(coll, { ...options, patchObjectType: 'traits', collectionId: options.collectionId || 'global' } as any);
const { em } = options;
this.em = em;
this.categories = new Categories([], {
@ -55,6 +56,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));
}

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

@ -0,0 +1,226 @@
// License: CC0 (no rights reserved).
// See 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),
];
}

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

@ -0,0 +1,198 @@
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);
});
});

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

@ -89,4 +89,37 @@ describe('ModelWithPatches', () => {
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 }),
},
});
class TrackedModel extends ModelWithPatches {
patchObjectType = 'model';
}
const model = new TrackedModel({ id: 'model-4', foo: 'bar' }, { em: { Patches: pm } });
expect(model.patchObjectType).toBe('model');
expect(model.id || model.get('id')).toBe('model-4');
pm.trackModel(model);
pm.apply(
{
id: 'patch-4',
changes: [{ op: 'replace', path: ['model', 'model-4', 'attributes', 'foo'], value: 'baz' }],
reverseChanges: [{ op: 'replace', path: ['model', 'model-4', 'attributes', 'foo'], value: 'bar' }],
},
{ external: true },
);
expect(model.get('foo')).toBe('baz');
expect(events).toHaveLength(0);
});
});

Loading…
Cancel
Save