diff --git a/packages/core/src/block_manager/model/Block.ts b/packages/core/src/block_manager/model/Block.ts index 72d1de962..611615638 100644 --- a/packages/core/src/block_manager/model/Block.ts +++ b/packages/core/src/block_manager/model/Block.ts @@ -1,4 +1,4 @@ -import ModelWithPatches from '../../patch_manager/ModelWithPatches'; +import { Model } from '../../common'; import { isFunction } from 'underscore'; import Editor from '../../editor'; import Category, { CategoryProperties } from '../../abstract/ModuleCategory'; @@ -74,8 +74,7 @@ export interface BlockProperties extends DraggableContent { * * @module docsjs.Block */ -export default class Block extends ModelWithPatches { - patchObjectType = 'block'; +export default class Block extends Model { defaults() { return { label: '', diff --git a/packages/core/src/block_manager/model/Blocks.ts b/packages/core/src/block_manager/model/Blocks.ts index de7725a58..9d36aeb20 100644 --- a/packages/core/src/block_manager/model/Blocks.ts +++ b/packages/core/src/block_manager/model/Blocks.ts @@ -1,21 +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 { +const CATEGORY_KEY = 'category'; + +export default class Blocks extends Collection { em: EditorModel; - patchObjectType = 'blocks'; constructor(coll: any[], options: { em: EditorModel }) { - super(coll, { ...options, patchObjectType: 'blocks', collectionId: 'global' } as any); + 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); } diff --git a/packages/core/src/device_manager/model/Device.ts b/packages/core/src/device_manager/model/Device.ts index 95059c8ca..81e64adcc 100644 --- a/packages/core/src/device_manager/model/Device.ts +++ b/packages/core/src/device_manager/model/Device.ts @@ -1,4 +1,4 @@ -import ModelWithPatches from '../../patch_manager/ModelWithPatches'; +import { Model } from '../../common'; /** @private */ export interface DeviceProperties { @@ -43,8 +43,7 @@ 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 ModelWithPatches { - patchObjectType = 'device'; +export default class Device extends Model { defaults() { return { name: '', diff --git a/packages/core/src/device_manager/model/Devices.ts b/packages/core/src/device_manager/model/Devices.ts index d10ea83b0..b107e59aa 100644 --- a/packages/core/src/device_manager/model/Devices.ts +++ b/packages/core/src/device_manager/model/Devices.ts @@ -1,11 +1,9 @@ -import CollectionWithPatches from '../../patch_manager/CollectionWithPatches'; +import { Collection } from '../../common'; import Device from './Device'; -export default class Devices extends CollectionWithPatches { - patchObjectType = 'devices'; - +export default class Devices extends Collection { constructor(models?: any, opts: any = {}) { - super(models, { ...opts, patchObjectType: 'devices', collectionId: opts.collectionId || 'global' } as any); + super(models, opts); } } diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 3da62aaab..c7c141332 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/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,7 +159,22 @@ type GetComponentStyleOpts = GetStyleOpts & { * @module docsjs.Component */ export default class Component extends StyleableModel { - patchObjectType = 'component'; + 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 */ @@ -293,75 +309,84 @@ export default class Component extends StyleableModel { 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 = {}; - 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 = {}; + 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(); } } @@ -1018,8 +1043,7 @@ export default class Component extends StyleableModel { // Have to add components after the init, otherwise the parent // is not visible const comps = new Components([], this.opt); - comps.parent = this; - comps.setCollectionId(this.getId() || this.cid); + comps.setParent(this); const components = this.get('components'); const addChild = !this.opt.avoidChildren; this.set('components', comps); diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index e64cdeb88..5bd1f13fd 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -6,6 +6,8 @@ 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, @@ -123,6 +125,22 @@ interface AddComponentOptions extends AddOptions { keepIds?: string[]; } +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 => + value != null && typeof value === 'object' && !Array.isArray(value); + +const getOrderKeyByUid = (orderMap: Record, 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 { parent?: Component; constructor(models: any, opt: ComponentsOptions) { - super(models, { ...opt, patchObjectType: 'components', collectionId: opt.collectionId }); + 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; @@ -149,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(); + 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 { + 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 = {}; + 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 || []; diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index e2b67926d..0468efa9d 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -2,7 +2,7 @@ import { isArray, isObject, isString, keys } from 'underscore'; 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'; @@ -45,10 +45,17 @@ type WithDataResolvers = { [P in keyof T]?: T[P] | DataResolverProps; }; -export default class StyleableModel extends ModelWithPatches< - 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 extends ModelWithPatches { em?: EditorModel; views: StyleableView[] = []; dataResolverWatchers: ModelDataResolverWatchers; @@ -316,6 +323,11 @@ export default class StyleableModel 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); diff --git a/packages/core/src/patch_manager/CollectionWithPatches.ts b/packages/core/src/patch_manager/CollectionWithPatches.ts index c0223cb63..94c721d3f 100644 --- a/packages/core/src/patch_manager/CollectionWithPatches.ts +++ b/packages/core/src/patch_manager/CollectionWithPatches.ts @@ -1,5 +1,5 @@ import { generateNKeysBetween } from '../utils/fractionalIndex'; -import { Collection, Model, AddOptions } from '../common'; +import { AddOptions, Collection, Model } from '../common'; import EditorModel from '../editor/model/Editor'; import PatchManager, { PatchChangeProps, PatchPath } from './index'; @@ -7,6 +7,7 @@ export interface CollectionWithPatchesOptions extends AddOptions { em?: EditorModel; collectionId?: string; patchObjectType?: string; + trackOrder?: boolean; } export type FractionalEntry = { @@ -30,6 +31,7 @@ export default class CollectionWithPatches extends Coll private pendingRemovals: Record = {}; private suppressSortRebuild = false; private isResetting = false; + private trackOrder = true; constructor(models?: any, options: CollectionWithPatchesOptions = {}) { const nextOptions = { ...options }; @@ -37,20 +39,26 @@ export default class CollectionWithPatches extends Coll this.em = nextOptions.em; this.collectionId = nextOptions.collectionId; this.patchObjectType = nextOptions.patchObjectType; - this.on('sort', this.handleSort, this); - this.rebuildFractionalMap(false); + 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; - if (pm?.isEnabled) { + const id = this.getPatchCollectionId(); + if (pm?.isEnabled && this.patchObjectType && id != null) { pm.trackCollection?.(this as any); } }); } get patchManager(): PatchManager | undefined { - return this.em?.Patches; + const pm = (this.em as any)?.Patches as PatchManager | undefined; + return pm?.isEnabled && this.patchObjectType ? pm : undefined; } setCollectionId(id: string) { @@ -61,7 +69,7 @@ export default class CollectionWithPatches extends Coll add(models: Array, options?: CollectionWithPatchesOptions): T[]; add(models: any, options?: CollectionWithPatchesOptions): any { const result = super.add(models, this.withEmOptions(options) as any); - !this.isResetting && this.assignKeysForMissingModels(); + this.trackOrder && !this.isResetting && this.assignKeysForMissingModels(); return result as any; } @@ -69,6 +77,8 @@ export default class CollectionWithPatches extends Coll remove(models: Array, 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); @@ -96,9 +106,11 @@ export default class CollectionWithPatches extends Coll this.isResetting = true; try { const result = super.reset(models, this.withEmOptions(options) as any); - this.fractionalMap = {}; - this.pendingRemovals = {}; - this.rebuildFractionalMap(); + if (this.trackOrder) { + this.fractionalMap = {}; + this.pendingRemovals = {}; + this.rebuildFractionalMap(); + } return result; } finally { this.isResetting = false; @@ -106,12 +118,13 @@ export default class CollectionWithPatches extends Coll } 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 || (this as any).cid; + return this.collectionId; } protected withEmOptions(options?: CollectionWithPatchesOptions) { @@ -123,6 +136,7 @@ export default class CollectionWithPatches extends Coll } 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 }; @@ -148,6 +162,7 @@ export default class CollectionWithPatches extends Coll } protected assignKeysForMissingModels() { + if (!this.trackOrder) return; let idx = 0; const models = this.models; diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts index 3c85f6c62..9cb94f7db 100644 --- a/packages/core/src/patch_manager/ModelWithPatches.ts +++ b/packages/core/src/patch_manager/ModelWithPatches.ts @@ -1,7 +1,7 @@ import { enablePatches, produceWithPatches } from 'immer'; import EditorModel from '../editor/model/Editor'; import { Model, ObjectHash, SetOptions } from '../common'; -import { serialize } from '../utils/mixins'; +import { createId, serialize } from '../utils/mixins'; import PatchManager, { PatchChangeProps, PatchPath } from './index'; enablePatches(); @@ -35,6 +35,39 @@ const normalizePatchPaths = (patches: PatchChangeProps[], prefix: PatchPath): Pa })); const syncDraftToState = (draft: any, target: any) => { + const isObject = (value: any): value is Record => + 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]; @@ -42,7 +75,51 @@ const syncDraftToState = (draft: any, target: any) => { }); Object.keys(target).forEach((key) => { - draft[key] = target[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 = (attrs: Partial): Partial => { + const attrsAny = attrs as any; + if (attrsAny && typeof attrsAny === 'object' && 'uid' in attrsAny) { + const { uid: _uid, ...rest } = attrsAny; + return rest as Partial; + } + + 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; }); }; @@ -62,6 +139,10 @@ export default class ModelWithPatches(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; - const objectId = this.getPatchObjectId(); - if (!pm || !objectId) { - return (super.set as any).apply(this, args); + 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); } - const { attrs, opts } = normalizeSetArgs(args); - const beforeState = serialize(this.attributes || {}); - const result = super.set(attrs as any, opts 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(beforeState, (draft: any) => { syncDraftToState(draft, afterState); }); - if (patches.length || inversePatches.length) { - const prefix: PatchPath = [this.patchObjectType as string, objectId, 'attributes']; + 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(patches, prefix)); - activePatch.reverseChanges.push(...normalizePatchPaths(inversePatches, prefix)); + activePatch.changes.push(...normalizePatchPaths(nextPatches, prefix)); + // Reverse changes should be applied in reverse order. + activePatch.reverseChanges.unshift(...normalizePatchPaths(nextInversePatches, prefix)); } return result; diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index 49728dbbd..b6a7ca333 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -49,6 +49,11 @@ const createPatchId = () => { 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; @@ -71,10 +76,9 @@ export default class PatchManager { 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 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; @@ -83,10 +87,9 @@ export default class PatchManager { 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 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]; } @@ -94,13 +97,9 @@ export default class PatchManager { 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 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; @@ -109,17 +108,17 @@ export default class PatchManager { 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 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(); @@ -294,7 +293,7 @@ export default class PatchManager { }); } - private withSuppressedTracking(cb: () => T): T { + withSuppressedTracking(cb: () => T): T { const prevSuppress = this.suppressTracking; this.suppressTracking = true; @@ -344,4 +343,6 @@ export default class PatchManager { this.emitter?.trigger?.(event, payload); } } + export { default as CollectionWithPatches } from './CollectionWithPatches'; +export { PatchObjectsRegistry, createRegistryApplyPatchHandler, type PatchUid } from './registry'; diff --git a/packages/core/src/patch_manager/registry.ts b/packages/core/src/patch_manager/registry.ts new file mode 100644 index 000000000..198d6363d --- /dev/null +++ b/packages/core/src/patch_manager/registry.ts @@ -0,0 +1,91 @@ +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 { + private byType: Record> = {}; + + 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(); + + 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); + }); + }; +}; + diff --git a/packages/core/src/trait_manager/model/Trait.ts b/packages/core/src/trait_manager/model/Trait.ts index 074d5b37c..79ce7a7ad 100644 --- a/packages/core/src/trait_manager/model/Trait.ts +++ b/packages/core/src/trait_manager/model/Trait.ts @@ -1,13 +1,12 @@ import { isString, isUndefined } from 'underscore'; import Category from '../../abstract/ModuleCategory'; -import { LocaleOptions, 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'; 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`. @@ -22,8 +21,7 @@ import ModelWithPatches from '../../patch_manager/ModelWithPatches'; * @module docsjs.Trait * */ -export default class Trait extends ModelWithPatches { - patchObjectType = 'trait'; +export default class Trait extends Model { target!: Component; em: EditorModel; view?: TraitView; diff --git a/packages/core/src/trait_manager/model/Traits.ts b/packages/core/src/trait_manager/model/Traits.ts index 44473a47b..131ea7210 100644 --- a/packages/core/src/trait_manager/model/Traits.ts +++ b/packages/core/src/trait_manager/model/Traits.ts @@ -14,10 +14,9 @@ export default class Traits extends CollectionWithCategories { target!: Component; tf: TraitFactory; categories = new Categories(); - patchObjectType = 'traits'; constructor(coll: TraitProperties[], options: { em: EditorModel; collectionId?: string }) { - super(coll, { ...options, patchObjectType: 'traits', collectionId: options.collectionId || 'global' } as any); + super(coll, options as any); const { em } = options; this.em = em; this.categories = new Categories([], { diff --git a/packages/core/src/utils/fractionalIndex.ts b/packages/core/src/utils/fractionalIndex.ts index 2af97b541..a159ada66 100644 --- a/packages/core/src/utils/fractionalIndex.ts +++ b/packages/core/src/utils/fractionalIndex.ts @@ -1,5 +1,5 @@ -// License: CC0 (no rights reserved). -// See https://github.com/rocicorp/fractional-indexing +// Based on rocicorp/fractional-indexing (CC0) +// https://github.com/rocicorp/fractional-indexing export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; diff --git a/packages/core/test/specs/patch_manager/components.js b/packages/core/test/specs/patch_manager/components.js new file mode 100644 index 000000000..ba764f1eb --- /dev/null +++ b/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); + }); +}); diff --git a/packages/core/test/specs/patch_manager/model/ModelWithPatches.js b/packages/core/test/specs/patch_manager/model/ModelWithPatches.js index 57900c6e2..af0a3cb28 100644 --- a/packages/core/test/specs/patch_manager/model/ModelWithPatches.js +++ b/packages/core/test/specs/patch_manager/model/ModelWithPatches.js @@ -10,6 +10,7 @@ describe('ModelWithPatches', () => { trigger: (event, payload) => events.push({ event, payload }), }, }); + pm.createId = () => 'uid-1'; const model = new ModelWithPatches({ id: 'model-1', foo: 'bar' }); model.em = { Patches: pm }; @@ -23,16 +24,17 @@ describe('ModelWithPatches', () => { 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', 'model-1', 'attributes', 'foo'], + path: ['model', 'uid-1', 'attributes', 'foo'], value: 'baz', }); expect(patch.reverseChanges[0]).toMatchObject({ op: 'replace', - path: ['model', 'model-1', 'attributes', 'foo'], + path: ['model', 'uid-1', 'attributes', 'foo'], value: 'bar', }); }); @@ -71,15 +73,15 @@ describe('ModelWithPatches', () => { }, }); - model = new ModelWithPatches({ id: 'model-3', foo: 'bar' }); + 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', 'model-3', 'attributes', 'foo'], value: 'applied' }], - reverseChanges: [{ op: 'replace', path: ['model', 'model-3', 'attributes', 'foo'], value: 'bar' }], + changes: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'applied' }], + reverseChanges: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'bar' }], }, { external: true }, ); @@ -99,27 +101,46 @@ describe('ModelWithPatches', () => { }, }); - 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'); + 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', 'model-4', 'attributes', 'foo'], value: 'baz' }], - reverseChanges: [{ op: 'replace', path: ['model', 'model-4', 'attributes', 'foo'], value: 'bar' }], + 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); }); }); diff --git a/packages/core/test/specs/patch_manager/registry.js b/packages/core/test/specs/patch_manager/registry.js new file mode 100644 index 000000000..5bc6d7ea5 --- /dev/null +++ b/packages/core/test/specs/patch_manager/registry.js @@ -0,0 +1,33 @@ +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'); + }); +}); +