From 611a031c8ea4adafc8fc834a481b6d27d7e3c056 Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Sun, 21 Dec 2025 12:30:31 +0200 Subject: [PATCH 01/17] add path manager --- packages/core/src/patch_manager/index.ts | 223 +++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 packages/core/src/patch_manager/index.ts diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts new file mode 100644 index 000000000..ebbe9d566 --- /dev/null +++ b/packages/core/src/patch_manager/index.ts @@ -0,0 +1,223 @@ +import { createId } from '../utils/mixins'; + +export type PatchOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; + +export type PatchChangeProps = { + op: PatchOp; + path: string; + value?: any; + from?: string; +}; + +export type PatchProps = { + id: string; + changes: PatchChangeProps[]; + reverseChanges: PatchChangeProps[]; + meta?: Record; +}; + +export type PatchApplyOptions = { + external?: boolean; + direction?: 'forward' | 'backward'; +}; + +export type PatchApplyHandler = (changes: PatchChangeProps[], options?: PatchApplyOptions) => void; + +export type PatchEventEmitter = { + trigger: (event: string, payload?: any) => void; +}; + +export type PatchManagerOptions = { + enabled?: boolean; + emitter?: PatchEventEmitter; + applyPatch?: PatchApplyHandler; +}; + +export const PatchManagerEvents = { + update: 'patch:update', + undo: 'patch:undo', + redo: 'patch:redo', +} as const; + +type InternalPatch = PatchProps & { recordable: boolean }; + +export default class PatchManager { + isEnabled: boolean; + private emitter?: PatchEventEmitter; + private applyHandler?: PatchApplyHandler; + private history: PatchProps[] = []; + private redoStack: PatchProps[] = []; + private activePatch?: InternalPatch; + private updateDepth = 0; + private finalizeScheduled = false; + private suppressTracking = false; + + constructor(options: PatchManagerOptions = {}) { + this.isEnabled = !!options.enabled; + this.emitter = options.emitter; + this.applyHandler = options.applyPatch; + } + + createOrGetCurrentPatch(): PatchProps { + if (!this.shouldRecord()) { + return this.createVoidPatch(); + } + + if (!this.activePatch) { + this.activePatch = this.createPatch(); + + if (!this.updateDepth) { + this.scheduleFinalize(); + } + } + + return this.activePatch; + } + + finalizeCurrentPatch(): void { + const patch = this.activePatch; + this.activePatch = undefined; + this.finalizeScheduled = false; + + if (!patch || !patch.recordable) return; + if (!patch.changes.length && !patch.reverseChanges.length) return; + + this.add(patch); + } + + update(cb: () => void): void { + if (!this.isEnabled) { + cb(); + return; + } + + this.updateDepth++; + this.createOrGetCurrentPatch(); + + try { + cb(); + } finally { + this.updateDepth--; + + if (this.updateDepth === 0) { + this.finalizeCurrentPatch(); + } + } + } + + add(patch: PatchProps): void { + if (!this.shouldRecord()) return; + + this.history.push(patch); + this.redoStack = []; + this.emit(PatchManagerEvents.update, patch); + } + + apply(patch: PatchProps, opts: { external?: boolean; addToHistory?: boolean } = {}): void { + if (!this.isEnabled) return; + + const { external = false } = opts; + const addToHistory = opts.addToHistory ?? !external; + + this.applyChanges(patch.changes, { external, direction: 'forward' }); + + if (addToHistory) { + this.history.push(patch); + this.redoStack = []; + this.emit(PatchManagerEvents.update, patch); + } + } + + undo(): PatchProps | undefined { + if (!this.isEnabled) return; + + this.finalizeCurrentPatch(); + const patch = this.history.pop(); + if (!patch) return; + + this.applyChanges(patch.reverseChanges, { direction: 'backward' }); + this.redoStack.push(patch); + this.emit(PatchManagerEvents.undo, patch); + + return patch; + } + + redo(): PatchProps | undefined { + if (!this.isEnabled) return; + + const patch = this.redoStack.pop(); + if (!patch) return; + + this.applyChanges(patch.changes, { direction: 'forward' }); + this.history.push(patch); + this.emit(PatchManagerEvents.redo, patch); + + return patch; + } + + getHistory(): PatchProps[] { + return this.history.slice(); + } + + getRedoStack(): PatchProps[] { + return this.redoStack.slice(); + } + + private applyChanges(changes: PatchChangeProps[], options: PatchApplyOptions = {}) { + if (!changes.length || !this.applyHandler) return; + + this.withSuppressedTracking(() => { + this.applyHandler?.(changes, options); + }); + } + + private withSuppressedTracking(cb: () => T): T { + const prevSuppress = this.suppressTracking; + this.suppressTracking = true; + + try { + return cb(); + } finally { + this.suppressTracking = prevSuppress; + } + } + + private shouldRecord() { + return this.isEnabled && !this.suppressTracking; + } + + private createPatch(): InternalPatch { + return { + id: createId(), + changes: [], + reverseChanges: [], + recordable: true, + }; + } + + private createVoidPatch(): InternalPatch { + return { + id: '', + changes: [], + reverseChanges: [], + recordable: false, + }; + } + + private scheduleFinalize() { + if (this.updateDepth || this.finalizeScheduled) return; + this.finalizeScheduled = true; + + Promise.resolve().then(() => { + this.finalizeScheduled = false; + + if (!this.updateDepth) { + this.finalizeCurrentPatch(); + } + }); + } + + private emit(event: string, payload: PatchProps): void { + this.emitter?.trigger?.(event, payload); + } +} From d2d589f0d6bf3052a0480c276f512d7a2123c2f9 Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Sun, 21 Dec 2025 16:28:43 +0200 Subject: [PATCH 02/17] update --- packages/core/src/patch_manager/index.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index ebbe9d566..7e850a4a6 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -13,7 +13,6 @@ export type PatchProps = { id: string; changes: PatchChangeProps[]; reverseChanges: PatchChangeProps[]; - meta?: Record; }; export type PatchApplyOptions = { @@ -41,6 +40,12 @@ export const PatchManagerEvents = { type InternalPatch = PatchProps & { recordable: boolean }; +const createPatchId = () => { + // Prefer UUID when available, fallback to legacy id generator + const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID; + return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId(); +}; + export default class PatchManager { isEnabled: boolean; private emitter?: PatchEventEmitter; @@ -113,12 +118,13 @@ export default class PatchManager { this.emit(PatchManagerEvents.update, patch); } - apply(patch: PatchProps, opts: { external?: boolean; addToHistory?: boolean } = {}): void { + apply(patch: PatchProps, opts: { external?: boolean } = {}): void { if (!this.isEnabled) return; const { external = false } = opts; - const addToHistory = opts.addToHistory ?? !external; + const addToHistory = !external; + this.finalizeCurrentPatch(); this.applyChanges(patch.changes, { external, direction: 'forward' }); if (addToHistory) { @@ -155,14 +161,6 @@ export default class PatchManager { return patch; } - getHistory(): PatchProps[] { - return this.history.slice(); - } - - getRedoStack(): PatchProps[] { - return this.redoStack.slice(); - } - private applyChanges(changes: PatchChangeProps[], options: PatchApplyOptions = {}) { if (!changes.length || !this.applyHandler) return; @@ -188,7 +186,7 @@ export default class PatchManager { private createPatch(): InternalPatch { return { - id: createId(), + id: createPatchId(), changes: [], reverseChanges: [], recordable: true, From c321883bb0d221ffddfb7b176a387731785dea50 Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Sun, 21 Dec 2025 17:05:39 +0200 Subject: [PATCH 03/17] update --- packages/core/package.json | 1 + .../src/patch_manager/ModelWithPatches.ts | 88 +++++++++++++++++++ packages/core/src/patch_manager/index.ts | 6 +- 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/patch_manager/ModelWithPatches.ts diff --git a/packages/core/package.json b/packages/core/package.json index 838879f5d..b33db3ed8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,7 @@ "url": "https://github.com/GrapesJS/grapesjs.git" }, "dependencies": { + "immer": "^10.1.1", "@types/backbone": "1.4.15", "backbone": "1.4.1", "backbone-undo": "0.2.6", diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts new file mode 100644 index 000000000..2a1926490 --- /dev/null +++ b/packages/core/src/patch_manager/ModelWithPatches.ts @@ -0,0 +1,88 @@ +import { enablePatches, produceWithPatches } from 'immer'; +import EditorModel from '../editor/model/Editor'; +import { Model, ObjectHash, SetOptions } from '../common'; +import { serialize } from '../utils/mixins'; +import PatchManager, { PatchChangeProps, PatchPath } from './index'; + +enablePatches(); + +type SetArgs = { + attrs: Partial; + opts: SetOptions; +}; + +const normalizeSetArgs = (args: any[]): SetArgs => { + const [first, second, third] = args; + + if (typeof first === 'string') { + return { + attrs: { [first]: second } as any, + opts: (third || {}) as SetOptions, + }; + } + + return { + attrs: (first || {}) as Partial, + opts: (second || {}) as SetOptions, + }; +}; + +const normalizePatchPaths = (patches: PatchChangeProps[], prefix: PatchPath): PatchChangeProps[] => + patches.map((patch) => ({ + ...patch, + path: [...prefix, ...patch.path], + ...(patch.from ? { from: [...prefix, ...patch.from] } : {}), + })); + +const syncDraftToState = (draft: any, target: any) => { + Object.keys(draft).forEach((key) => { + if (!(key in target)) { + delete draft[key]; + } + }); + + Object.keys(target).forEach((key) => { + draft[key] = target[key]; + }); +}; + +export default class ModelWithPatches extends Model { + em?: EditorModel; + patchObjectType?: string; + + protected get patchManager(): PatchManager | undefined { + const pm = (this.em as any)?.Patches as PatchManager | undefined; + return pm?.isEnabled && this.patchObjectType ? pm : undefined; + } + + protected getPatchObjectId(): string | number | undefined { + const id = (this as any).id ?? (this as any).get?.('id'); + return id ?? (this as any).cid; + } + + set(...args: any[]): this { + const pm = this.patchManager; + const objectId = this.getPatchObjectId(); + + if (!pm || !objectId) { + return (super.set as any).apply(this, args); + } + + const { attrs, opts } = normalizeSetArgs(args); + const beforeState = serialize(this.attributes || {}); + const result = super.set(attrs as any, opts as any); + const afterState = serialize(this.attributes || {}); + 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 activePatch = pm.createOrGetCurrentPatch(); + activePatch.changes.push(...normalizePatchPaths(patches, prefix)); + activePatch.reverseChanges.push(...normalizePatchPaths(inversePatches, prefix)); + } + + return result; + } +} diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index 7e850a4a6..017d503c8 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -2,11 +2,13 @@ import { createId } from '../utils/mixins'; export type PatchOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; +export type PatchPath = Array; + export type PatchChangeProps = { op: PatchOp; - path: string; + path: PatchPath; value?: any; - from?: string; + from?: PatchPath; }; export type PatchProps = { From f0259a4587e7775351ee0f4ae2756ca76595f705 Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Thu, 25 Dec 2025 20:07:49 +0200 Subject: [PATCH 04/17] fix --- pnpm-lock.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfc3ce63b..736b8707e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: html-entities: specifier: ~1.4.0 version: 1.4.0 + immer: + specifier: ^10.1.1 + version: 10.2.0 promise-polyfill: specifier: 8.3.0 version: 8.3.0 @@ -4731,6 +4734,9 @@ packages: immediate@3.3.0: resolution: {integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + immutable@4.3.7: resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} @@ -14438,6 +14444,8 @@ snapshots: immediate@3.3.0: {} + immer@10.2.0: {} + immutable@4.3.7: {} import-cwd@2.1.0: From f1fb012fd3cac39fa92c9dce2f5f8be3be8bb91c Mon Sep 17 00:00:00 2001 From: Kaleniuk Date: Thu, 25 Dec 2025 20:33:02 +0200 Subject: [PATCH 05/17] update tests --- .../core/test/specs/patch_manager/index.js | 92 +++++++++++++++++++ .../patch_manager/model/ModelWithPatches.js | 92 +++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 packages/core/test/specs/patch_manager/index.js create mode 100644 packages/core/test/specs/patch_manager/model/ModelWithPatches.js diff --git a/packages/core/test/specs/patch_manager/index.js b/packages/core/test/specs/patch_manager/index.js new file mode 100644 index 000000000..21aaad0ea --- /dev/null +++ b/packages/core/test/specs/patch_manager/index.js @@ -0,0 +1,92 @@ +import PatchManager, { PatchManagerEvents } from 'patch_manager'; + +describe('PatchManager', () => { + test('Records a patch during update and emits update event', () => { + const events = []; + const pm = new PatchManager({ + enabled: true, + emitter: { + trigger: (event, payload) => events.push({ event, payload }), + }, + }); + + pm.update(() => { + const patch = pm.createOrGetCurrentPatch(); + patch.changes.push({ op: 'replace', path: ['value'], value: 1 }); + patch.reverseChanges.push({ op: 'replace', path: ['value'], value: 0 }); + }); + + expect(events).toHaveLength(1); + expect(events[0].event).toBe(PatchManagerEvents.update); + expect(events[0].payload.changes).toHaveLength(1); + expect(events[0].payload.reverseChanges).toHaveLength(1); + }); + + test('Applies patches and respects the external flag', () => { + const calls = []; + const events = []; + const pm = new PatchManager({ + enabled: true, + applyPatch: (changes, options) => calls.push({ changes, options }), + emitter: { + trigger: (event) => events.push(event), + }, + }); + + const patch = { + id: 'patch-1', + changes: [{ op: 'add', path: ['value'], value: 1 }], + reverseChanges: [{ op: 'remove', path: ['value'] }], + }; + + pm.apply(patch); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + changes: patch.changes, + options: { external: false, direction: 'forward' }, + }); + expect(events).toEqual([PatchManagerEvents.update]); + + calls.length = 0; + events.length = 0; + + pm.apply(patch, { external: true }); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual({ + changes: patch.changes, + options: { external: true, direction: 'forward' }, + }); + expect(events).toHaveLength(0); + }); + + test('Undo and redo apply reverse/forward changes', () => { + const calls = []; + const events = []; + const pm = new PatchManager({ + enabled: true, + applyPatch: (changes, options) => calls.push({ changes, options }), + emitter: { + trigger: (event) => events.push(event), + }, + }); + + const patch = { + id: 'patch-2', + changes: [{ op: 'replace', path: ['value'], value: 2 }], + reverseChanges: [{ op: 'replace', path: ['value'], value: 1 }], + }; + + pm.add(patch); + + const undoPatch = pm.undo(); + const redoPatch = pm.redo(); + + expect(undoPatch).toBe(patch); + expect(redoPatch).toBe(patch); + expect(calls[0]).toEqual({ changes: patch.reverseChanges, options: { direction: 'backward' } }); + expect(calls[1]).toEqual({ changes: patch.changes, options: { direction: 'forward' } }); + expect(events).toEqual([PatchManagerEvents.update, PatchManagerEvents.undo, PatchManagerEvents.redo]); + }); +}); diff --git a/packages/core/test/specs/patch_manager/model/ModelWithPatches.js b/packages/core/test/specs/patch_manager/model/ModelWithPatches.js new file mode 100644 index 000000000..1f0c8de02 --- /dev/null +++ b/packages/core/test/specs/patch_manager/model/ModelWithPatches.js @@ -0,0 +1,92 @@ +import PatchManager, { PatchManagerEvents } from 'patch_manager'; +import ModelWithPatches from 'patch_manager/ModelWithPatches'; + +describe('ModelWithPatches', () => { + test('set records patch with normalized path', async () => { + const events = []; + const pm = new PatchManager({ + enabled: true, + emitter: { + trigger: (event, payload) => events.push({ event, payload }), + }, + }); + + const model = new ModelWithPatches({ id: 'model-1', foo: 'bar' }); + model.em = { Patches: pm }; + model.patchObjectType = 'model'; + + model.set('foo', 'baz'); + + await Promise.resolve(); + + expect(events).toHaveLength(1); + expect(events[0].event).toBe(PatchManagerEvents.update); + + const patch = events[0].payload; + expect(patch.changes).toHaveLength(1); + expect(patch.reverseChanges).toHaveLength(1); + expect(patch.changes[0]).toMatchObject({ + op: 'replace', + path: ['model', 'model-1', 'attributes', 'foo'], + value: 'baz', + }); + expect(patch.reverseChanges[0]).toMatchObject({ + op: 'replace', + path: ['model', 'model-1', 'attributes', 'foo'], + value: 'bar', + }); + }); + + test('set skips patch recording without a patch object type', async () => { + const events = []; + const pm = new PatchManager({ + enabled: true, + emitter: { + trigger: (event, payload) => events.push({ event, payload }), + }, + }); + + const model = new ModelWithPatches({ id: 'model-2', foo: 'bar' }); + model.em = { Patches: pm }; + + model.set('foo', 'baz'); + + await Promise.resolve(); + + expect(model.get('foo')).toBe('baz'); + expect(events).toHaveLength(0); + }); + + test('apply handler changes do not create patches while tracking is suppressed', async () => { + const events = []; + let model; + + const pm = new PatchManager({ + enabled: true, + emitter: { + trigger: (event, payload) => events.push({ event, payload }), + }, + applyPatch: () => { + model.set('foo', 'applied'); + }, + }); + + model = new ModelWithPatches({ 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' }], + }, + { external: true }, + ); + + await Promise.resolve(); + + expect(model.get('foo')).toBe('applied'); + expect(events).toHaveLength(0); + }); +}); From 75fb149432b0d6c76ff262b1686bf1d4f21edee8 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Wed, 14 Jan 2026 20:11:46 +0200 Subject: [PATCH 06/17] update --- packages/core/src/patch_manager/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index 017d503c8..6206c2cd9 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -126,7 +126,10 @@ export default class PatchManager { const { external = false } = opts; const addToHistory = !external; - this.finalizeCurrentPatch(); + if (addToHistory) { + this.finalizeCurrentPatch(); + } + this.applyChanges(patch.changes, { external, direction: 'forward' }); if (addToHistory) { @@ -153,6 +156,8 @@ export default class PatchManager { redo(): PatchProps | undefined { if (!this.isEnabled) return; + this.finalizeCurrentPatch(); + const patch = this.redoStack.pop(); if (!patch) return; From b9d3486994d86fa76ae48d0353194080c52d6c57 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Fri, 16 Jan 2026 22:53:32 +0200 Subject: [PATCH 07/17] add pm to any modules --- .../src/abstract/CollectionWithCategories.ts | 5 +- packages/core/src/asset_manager/index.ts | 8 +- .../core/src/asset_manager/model/Asset.ts | 5 +- .../core/src/asset_manager/model/Assets.ts | 4 +- packages/core/src/block_manager/index.ts | 4 +- .../core/src/block_manager/model/Block.ts | 5 +- .../core/src/block_manager/model/Blocks.ts | 3 +- packages/core/src/css_composer/index.ts | 2 +- .../core/src/css_composer/model/CssRule.ts | 1 + .../core/src/css_composer/model/CssRules.ts | 13 +- packages/core/src/device_manager/index.ts | 2 +- .../core/src/device_manager/model/Device.ts | 5 +- .../core/src/device_manager/model/Devices.ts | 10 +- packages/core/src/dom_components/index.ts | 8 +- .../src/dom_components/model/Component.ts | 2 + .../src/dom_components/model/Components.ts | 8 +- .../domain_abstract/model/StyleableModel.ts | 5 +- packages/core/src/editor/config/config.ts | 14 + packages/core/src/editor/index.ts | 4 + packages/core/src/editor/model/Editor.ts | 12 + packages/core/src/editor/types.ts | 27 +- packages/core/src/pages/index.ts | 2 +- packages/core/src/pages/model/Page.ts | 4 +- packages/core/src/pages/model/Pages.ts | 12 +- .../patch_manager/CollectionWithPatches.ts | 305 ++++++++++++++++++ .../src/patch_manager/ModelWithPatches.ts | 24 +- packages/core/src/patch_manager/index.ts | 125 ++++++- packages/core/src/selector_manager/index.ts | 13 +- .../src/selector_manager/model/Selector.ts | 4 +- .../src/selector_manager/model/Selectors.ts | 10 +- .../core/src/trait_manager/model/Trait.ts | 6 +- .../core/src/trait_manager/model/Traits.ts | 7 +- packages/core/src/utils/fractionalIndex.ts | 226 +++++++++++++ .../collection/CollectionWithPatches.js | 198 ++++++++++++ .../patch_manager/model/ModelWithPatches.js | 33 ++ 35 files changed, 1065 insertions(+), 51 deletions(-) create mode 100644 packages/core/src/patch_manager/CollectionWithPatches.ts create mode 100644 packages/core/src/utils/fractionalIndex.ts create mode 100644 packages/core/test/specs/patch_manager/collection/CollectionWithPatches.js diff --git a/packages/core/src/abstract/CollectionWithCategories.ts b/packages/core/src/abstract/CollectionWithCategories.ts index 7b334366a..c5d640100 100644 --- a/packages/core/src/abstract/CollectionWithCategories.ts +++ b/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> extends Collection { +export abstract class CollectionWithCategories> extends CollectionWithPatches { abstract getCategories(): Categories; initCategory(model: T) { diff --git a/packages/core/src/asset_manager/index.ts b/packages/core/src/asset_manager/index.ts index 5dd338121..794a7ff90 100644 --- a/packages/core/src/asset_manager/index.ts +++ b/packages/core/src/asset_manager/index.ts @@ -63,7 +63,13 @@ export default class AssetManager extends ItemManagerModule {} diff --git a/packages/core/src/block_manager/index.ts b/packages/core/src/block_manager/index.ts index 7b83eeeba..09e61cd91 100644 --- a/packages/core/src/block_manager/index.ts +++ b/packages/core/src/block_manager/index.ts @@ -61,7 +61,7 @@ export default class BlockManager extends ItemManagerModule this.__trgCustom(), 0); @@ -335,7 +335,7 @@ export default class BlockManager extends ItemManagerModule { +export default class Block extends ModelWithPatches { + patchObjectType = 'block'; defaults() { return { label: '', diff --git a/packages/core/src/block_manager/model/Blocks.ts b/packages/core/src/block_manager/model/Blocks.ts index e982dea33..de7725a58 100644 --- a/packages/core/src/block_manager/model/Blocks.ts +++ b/packages/core/src/block_manager/model/Blocks.ts @@ -4,9 +4,10 @@ import Block from './Block'; export default class Blocks extends CollectionWithCategories { 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); } diff --git a/packages/core/src/css_composer/index.ts b/packages/core/src/css_composer/index.ts index 4fd3e4f68..61b5a03fa 100644 --- a/packages/core/src/css_composer/index.ts +++ b/packages/core/src/css_composer/index.ts @@ -103,7 +103,7 @@ export default class CssComposer extends ItemManagerModule { + patchObjectType = 'css-rule'; config: CssRuleProperties; em?: EditorModel; opt: any; diff --git a/packages/core/src/css_composer/model/CssRules.ts b/packages/core/src/css_composer/model/CssRules.ts index 36d24c7df..00ebec6c4 100644 --- a/packages/core/src/css_composer/model/CssRules.ts +++ b/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 { +export default class CssRules extends CollectionWithPatches { 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 { } 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 { 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]); } } diff --git a/packages/core/src/device_manager/index.ts b/packages/core/src/device_manager/index.ts index acc4a3edc..9b8d908de 100644 --- a/packages/core/src/device_manager/index.ts +++ b/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)); diff --git a/packages/core/src/device_manager/model/Device.ts b/packages/core/src/device_manager/model/Device.ts index 81e64adcc..397cd24b8 100644 --- a/packages/core/src/device_manager/model/Device.ts +++ b/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 { +export default class Device extends ModelWithPatches { + patchObjectType = 'device'; defaults() { return { name: '', diff --git a/packages/core/src/device_manager/model/Devices.ts b/packages/core/src/device_manager/model/Devices.ts index 4115fa0a2..d10ea83b0 100644 --- a/packages/core/src/device_manager/model/Devices.ts +++ b/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 {} +export default class Devices extends CollectionWithPatches { + patchObjectType = 'devices'; + + constructor(models?: any, opts: any = {}) { + super(models, { ...opts, patchObjectType: 'devices', collectionId: opts.collectionId || 'global' } as any); + } +} Devices.prototype.model = Device; diff --git a/packages/core/src/dom_components/index.ts b/packages/core/src/dom_components/index.ts index 807492318..8fc0cd147 100644 --- a/packages/core/src/dom_components/index.ts +++ b/packages/core/src/dom_components/index.ts @@ -364,7 +364,13 @@ export default class ComponentManager extends ItemManagerModule { + patchObjectType = 'component'; /** * @private * @ts-ignore */ @@ -1018,6 +1019,7 @@ export default class Component extends StyleableModel { // 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); diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index f62832eaf..e64cdeb88 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/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 { @@ -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); diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 36005186f..90aaeb8b5 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/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; @@ -44,7 +45,7 @@ type WithDataResolvers = { [P in keyof T]?: T[P] | DataResolverProps; }; -export default class StyleableModel extends Model { +export default class StyleableModel extends ModelWithPatches { em?: EditorModel; views: StyleableView[] = []; dataResolverWatchers: ModelDataResolverWatchers; diff --git a/packages/core/src/editor/config/config.ts b/packages/core/src/editor/config/config.ts index 6a3f81cdc..15aa2630c 100644 --- a/packages/core/src/editor/config/config.ts +++ b/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: {}, diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 24fb7a9b3..b4268fcc3 100644 --- a/packages/core/src/editor/index.ts +++ b/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 { get UndoManager(): UndoManagerModule { return this.em.UndoManager; } + get Patches(): PatchManager { + return this.em.Patches; + } get RichTextEditor(): RichTextEditorModule { return this.em.RichTextEditor; } diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 1dc90fee5..cb9a3d576 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/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'); diff --git a/packages/core/src/editor/types.ts b/packages/core/src/editor/types.ts index faf341c2d..eb951e7ee 100644 --- a/packages/core/src/editor/types.ts +++ b/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 = Parameters[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 diff --git a/packages/core/src/pages/index.ts b/packages/core/src/pages/index.ts index 0005063c6..f7ac155bf 100644 --- a/packages/core/src/pages/index.ts +++ b/packages/core/src/pages/index.ts @@ -73,7 +73,7 @@ export default class PageManager extends ItemManagerModule { +export default class Page extends ModelWithPatches { + patchObjectType = 'page'; defaults() { return { name: '', diff --git a/packages/core/src/pages/model/Pages.ts b/packages/core/src/pages/model/Pages.ts index 35733fa67..162b910ba 100644 --- a/packages/core/src/pages/model/Pages.ts +++ b/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 { - constructor(models: any, em: EditorModel) { - super(models); +export default class Pages extends CollectionWithPatches { + 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); diff --git a/packages/core/src/patch_manager/CollectionWithPatches.ts b/packages/core/src/patch_manager/CollectionWithPatches.ts new file mode 100644 index 000000000..609b150b6 --- /dev/null +++ b/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 = { + id: string; + key: string; + model?: T | undefined; +}; + +type PendingRemoval = { + oldKey: string; + patch: any; + change: PatchChangeProps; + reverse: PatchChangeProps; +}; + +export default class CollectionWithPatches extends Collection { + em?: EditorModel; + collectionId?: string; + patchObjectType?: string; + private fractionalMap: Record = {}; + private pendingRemovals: Record = {}; + 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 = {}; + + 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[] { + 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); + } +} diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts index 2a1926490..3c85f6c62 100644 --- a/packages/core/src/patch_manager/ModelWithPatches.ts +++ b/packages/core/src/patch_manager/ModelWithPatches.ts @@ -50,12 +50,34 @@ export default class ModelWithPatches { + 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; } diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index 6206c2cd9..81854c30b 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/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> = {}; + private trackedCollections: Record> = {}; 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(); + + 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'; diff --git a/packages/core/src/selector_manager/index.ts b/packages/core/src/selector_manager/index.ts index 274660e79..5bea7abb5 100644 --- a/packages/core/src/selector_manager/index.ts +++ b/packages/core/src/selector_manager/index.ts @@ -106,14 +106,21 @@ export default class SelectorManager extends ItemManagerModule( config.states!.map((state: any) => new State(state)), { model: State }, diff --git a/packages/core/src/selector_manager/model/Selector.ts b/packages/core/src/selector_manager/model/Selector.ts index 0de65ffc7..6a8cf22a4 100644 --- a/packages/core/src/selector_manager/model/Selector.ts +++ b/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 { +export default class Selector extends ModelWithPatches { + patchObjectType = 'selector'; defaults() { return { name: '', diff --git a/packages/core/src/selector_manager/model/Selectors.ts b/packages/core/src/selector_manager/model/Selectors.ts index 556729e87..25e7a410b 100644 --- a/packages/core/src/selector_manager/model/Selectors.ts +++ b/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 { +export default class Selectors extends CollectionWithPatches { + 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}`; } diff --git a/packages/core/src/trait_manager/model/Trait.ts b/packages/core/src/trait_manager/model/Trait.ts index 2c5622902..5c6feb586 100644 --- a/packages/core/src/trait_manager/model/Trait.ts +++ b/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 { +export default class Trait extends ModelWithPatches { + patchObjectType = 'trait'; 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 c0177e080..44473a47b 100644 --- a/packages/core/src/trait_manager/model/Traits.ts +++ b/packages/core/src/trait_manager/model/Traits.ts @@ -14,9 +14,10 @@ export default class Traits extends CollectionWithCategories { 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 { 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)); } diff --git a/packages/core/src/utils/fractionalIndex.ts b/packages/core/src/utils/fractionalIndex.ts new file mode 100644 index 000000000..32aff3fd7 --- /dev/null +++ b/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), + ]; +} diff --git a/packages/core/test/specs/patch_manager/collection/CollectionWithPatches.js b/packages/core/test/specs/patch_manager/collection/CollectionWithPatches.js new file mode 100644 index 000000000..1e0df9c26 --- /dev/null +++ b/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); + }); +}); diff --git a/packages/core/test/specs/patch_manager/model/ModelWithPatches.js b/packages/core/test/specs/patch_manager/model/ModelWithPatches.js index 1f0c8de02..57900c6e2 100644 --- a/packages/core/test/specs/patch_manager/model/ModelWithPatches.js +++ b/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); + }); }); From 5132acd384f589aa5a83ceabc5f6d29b79dc31f4 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Tue, 20 Jan 2026 13:28:36 +0200 Subject: [PATCH 08/17] update tests --- .../core/src/asset_manager/model/Asset.ts | 2 +- .../core/src/asset_manager/model/Assets.ts | 19 +++++++- .../core/src/block_manager/model/Block.ts | 2 +- packages/core/src/commands/view/OpenAssets.ts | 2 +- .../core/src/device_manager/model/Device.ts | 2 +- .../domain_abstract/model/StyleableModel.ts | 7 ++- packages/core/src/pages/model/Page.ts | 2 +- .../patch_manager/CollectionWithPatches.ts | 47 ++++++++++--------- packages/core/src/patch_manager/index.ts | 8 ++-- .../src/selector_manager/model/Selector.ts | 2 +- .../core/src/trait_manager/model/Trait.ts | 2 +- 11 files changed, 58 insertions(+), 37 deletions(-) diff --git a/packages/core/src/asset_manager/model/Asset.ts b/packages/core/src/asset_manager/model/Asset.ts index d298c513a..01b2eed2a 100644 --- a/packages/core/src/asset_manager/model/Asset.ts +++ b/packages/core/src/asset_manager/model/Asset.ts @@ -1,5 +1,5 @@ import { result } from 'underscore'; -import ModelWithPatches from 'patch_manager/ModelWithPatches'; +import ModelWithPatches from '../../patch_manager/ModelWithPatches'; /** * @property {String} type Asset type, eg. `'image'`. diff --git a/packages/core/src/asset_manager/model/Assets.ts b/packages/core/src/asset_manager/model/Assets.ts index f49fdb4a7..7af0a5649 100644 --- a/packages/core/src/asset_manager/model/Assets.ts +++ b/packages/core/src/asset_manager/model/Assets.ts @@ -4,9 +4,24 @@ import AssetImage from './AssetImage'; import AssetImageView from '../view/AssetImageView'; import TypeableCollection from '../../domain_abstract/model/TypeableCollection'; -const TypeableCollectionExt = CollectionWithPatches.extend(TypeableCollection); +export default class Assets extends CollectionWithPatches { + constructor(models?: any, options?: any) { + super(models, options); + } +} -export default class Assets extends TypeableCollectionExt {} +export interface Assets { + types: any[]; + target?: any; + onSelect?: any; + getTypes(): any[]; + getType(id: string): any; + getBaseType(): any; + recognizeType(value: any): any; + addType(id: string, definition: any): void; +} + +Object.assign(Assets.prototype, TypeableCollection); Assets.prototype.types = [ { diff --git a/packages/core/src/block_manager/model/Block.ts b/packages/core/src/block_manager/model/Block.ts index dcee033a2..72d1de962 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 ModelWithPatches from '../../patch_manager/ModelWithPatches'; import { isFunction } from 'underscore'; import Editor from '../../editor'; import Category, { CategoryProperties } from '../../abstract/ModuleCategory'; diff --git a/packages/core/src/commands/view/OpenAssets.ts b/packages/core/src/commands/view/OpenAssets.ts index c76cc4fb1..c7c61fa53 100644 --- a/packages/core/src/commands/view/OpenAssets.ts +++ b/packages/core/src/commands/view/OpenAssets.ts @@ -49,7 +49,7 @@ export default { am.__trgCustom(); } else { if (!this.rendered || types) { - let assets: Asset[] = am.getAll().filter((i: Asset) => i); + let assets: Asset[] = am.getAll().filter((i: Asset) => !!i); if (types && types.length) { assets = assets.filter((a) => types.indexOf(a.get('type')) !== -1); diff --git a/packages/core/src/device_manager/model/Device.ts b/packages/core/src/device_manager/model/Device.ts index 397cd24b8..95059c8ca 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 ModelWithPatches from '../../patch_manager/ModelWithPatches'; /** @private */ export interface DeviceProperties { diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 90aaeb8b5..e2b67926d 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -13,7 +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'; +import ModelWithPatches from '../../patch_manager/ModelWithPatches'; export type StyleProps = Record; @@ -45,7 +45,10 @@ type WithDataResolvers = { [P in keyof T]?: T[P] | DataResolverProps; }; -export default class StyleableModel extends ModelWithPatches { +export default class StyleableModel extends ModelWithPatches< + T, + UpdateStyleOptions +> { em?: EditorModel; views: StyleableView[] = []; dataResolverWatchers: ModelDataResolverWatchers; diff --git a/packages/core/src/pages/model/Page.ts b/packages/core/src/pages/model/Page.ts index b16f2b2e5..5db010a0f 100644 --- a/packages/core/src/pages/model/Page.ts +++ b/packages/core/src/pages/model/Page.ts @@ -6,7 +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'; +import ModelWithPatches from '../../patch_manager/ModelWithPatches'; /** @private */ export interface PageProperties { diff --git a/packages/core/src/patch_manager/CollectionWithPatches.ts b/packages/core/src/patch_manager/CollectionWithPatches.ts index 609b150b6..c0223cb63 100644 --- a/packages/core/src/patch_manager/CollectionWithPatches.ts +++ b/packages/core/src/patch_manager/CollectionWithPatches.ts @@ -32,10 +32,11 @@ export default class CollectionWithPatches extends Coll private isResetting = false; constructor(models?: any, options: CollectionWithPatchesOptions = {}) { - super(models, options); - this.em = options.em; - this.collectionId = options.collectionId; - this.patchObjectType = options.patchObjectType; + const nextOptions = { ...options }; + super(models, nextOptions); + this.em = nextOptions.em; + this.collectionId = nextOptions.collectionId; + this.patchObjectType = nextOptions.patchObjectType; this.on('sort', this.handleSort, this); this.rebuildFractionalMap(false); @@ -48,16 +49,6 @@ export default class CollectionWithPatches extends Coll }); } - // 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; } @@ -66,14 +57,18 @@ export default class CollectionWithPatches extends Coll this.collectionId = id; } - add(models: any, options?: CollectionWithPatchesOptions) { - const result = super.add(models, options); + add(model: T | {}, options?: CollectionWithPatchesOptions): T; + 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(); - return result; + return result as any; } - remove(...args: any[]) { - const removed = super.remove(...args); + remove(model: T | {}, options?: any): T; + remove(models: Array, options?: any): T[]; + remove(models: any, options?: any): any { + const removed = super.remove(models, options as any); const removedModels = Array.isArray(removed) ? removed : removed ? [removed] : []; removedModels.forEach((model) => { const id = this.getModelId(model as any); @@ -100,7 +95,7 @@ export default class CollectionWithPatches extends Coll reset(models?: any, options?: CollectionWithPatchesOptions) { this.isResetting = true; try { - const result = super.reset(models, options); + const result = super.reset(models, this.withEmOptions(options) as any); this.fractionalMap = {}; this.pendingRemovals = {}; this.rebuildFractionalMap(); @@ -116,7 +111,15 @@ export default class CollectionWithPatches extends Coll } protected getPatchCollectionId(): string | undefined { - return this.collectionId || this.cid; + return this.collectionId || (this as any).cid; + } + + protected withEmOptions(options?: CollectionWithPatchesOptions) { + const nextOptions = options ? { ...options } : {}; + if (this.em && nextOptions.em == null) { + nextOptions.em = this.em; + } + return nextOptions; } protected rebuildFractionalMap(record: boolean = true) { @@ -262,7 +265,7 @@ export default class CollectionWithPatches extends Coll if (op === 'remove') { delete this.fractionalMap[id]; const model = this.getModelByPatchId(id); - model && Collection.prototype.remove.call(this, model); + model && Collection.prototype.remove.call(this, model as any); return; } diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index 81854c30b..49728dbbd 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -73,7 +73,7 @@ export default class PatchManager { 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; + 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] || {}; @@ -85,7 +85,7 @@ export default class PatchManager { 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; + 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]; @@ -99,7 +99,7 @@ export default class PatchManager { const hasGetterId = typeof idFromGetter === 'string' ? idFromGetter !== '' : typeof idFromGetter === 'number'; const id = hasGetterId ? idFromGetter - : collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid; + : (collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid); if (!type || id == null) return; const idStr = String(id); this.trackedCollections[type] = this.trackedCollections[type] || {}; @@ -114,7 +114,7 @@ export default class PatchManager { const hasGetterId = typeof idFromGetter === 'string' ? idFromGetter !== '' : typeof idFromGetter === 'number'; const id = hasGetterId ? idFromGetter - : collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid; + : (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]; diff --git a/packages/core/src/selector_manager/model/Selector.ts b/packages/core/src/selector_manager/model/Selector.ts index 6a8cf22a4..cea9b50f4 100644 --- a/packages/core/src/selector_manager/model/Selector.ts +++ b/packages/core/src/selector_manager/model/Selector.ts @@ -2,7 +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'; +import ModelWithPatches from '../../patch_manager/ModelWithPatches'; const TYPE_CLASS = 1; const TYPE_ID = 2; diff --git a/packages/core/src/trait_manager/model/Trait.ts b/packages/core/src/trait_manager/model/Trait.ts index 5c6feb586..074d5b37c 100644 --- a/packages/core/src/trait_manager/model/Trait.ts +++ b/packages/core/src/trait_manager/model/Trait.ts @@ -7,7 +7,7 @@ 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'; +import ModelWithPatches from '../../patch_manager/ModelWithPatches'; /** * @property {String} id Trait id, eg. `my-trait-id`. From df6b01480380bd69461a4dc9d97800a2d3a34352 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Tue, 20 Jan 2026 13:39:41 +0200 Subject: [PATCH 09/17] fix build --- docs/api/assets.md | 2 ++ docs/api/datasources.md | 21 +++++++++++++++---- docs/api/device.md | 2 +- docs/api/editor.md | 18 ++++++++++++++++ docs/api/selector.md | 2 +- .../core/src/asset_manager/model/Assets.ts | 4 +++- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/docs/api/assets.md b/docs/api/assets.md index 637d1fef3..37a235080 100644 --- a/docs/api/assets.md +++ b/docs/api/assets.md @@ -85,6 +85,8 @@ editor.on('asset:custom', ({ container, assets, ... }) => { ... }); editor.on('asset', ({ event, model, ... }) => { ... }); ``` +* AssetsEventCallback + ## Methods * [open][2] diff --git a/docs/api/datasources.md b/docs/api/datasources.md index 108caa2e3..ae853a24b 100644 --- a/docs/api/datasources.md +++ b/docs/api/datasources.md @@ -113,6 +113,18 @@ const ds = dsm.get('my_data_source_id'); Returns **[DataSource]** Data source. +## getAll + +Return all data sources. + +### Examples + +```javascript +const ds = dsm.getAll(); +``` + +Returns **[Array][8]<[DataSource]>** + ## getValue Get value from data sources by path. @@ -121,6 +133,7 @@ Get value from data sources by path. * `path` **[String][7]** Path to value. * `defValue` **any** Default value if the path is not found. +* `opts` **{context: Record<[string][7], any>?}?** Returns **any** const value = dsm.getValue('ds\_id.record\_id.propName', 'defaultValue'); @@ -139,7 +152,7 @@ Set value in data sources by path. dsm.setValue('ds_id.record_id.propName', 'new value'); ``` -Returns **[Boolean][8]** Returns true if the value was set successfully +Returns **[Boolean][9]** Returns true if the value was set successfully ## remove @@ -183,7 +196,7 @@ data record, and optional property path. Store data sources to a JSON object. -Returns **[Array][9]** Stored data sources. +Returns **[Array][8]** Stored data sources. ## load @@ -209,6 +222,6 @@ Returns **[Object][6]** Loaded data sources. [7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean +[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array -[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean diff --git a/docs/api/device.md b/docs/api/device.md index dca116c9e..e1a77add2 100644 --- a/docs/api/device.md +++ b/docs/api/device.md @@ -2,7 +2,7 @@ ## Device - +**Extends ModelWithPatches** ### Properties diff --git a/docs/api/editor.md b/docs/api/editor.md index 35b30e20b..49466fa2e 100644 --- a/docs/api/editor.md +++ b/docs/api/editor.md @@ -30,6 +30,24 @@ editor.on('undo', () => { ... }); editor.on('redo', () => { ... }); ``` +* `patch:update` Patch finalized. + +```javascript +editor.on('patch:update', (patch) => { ... }); +``` + +* `patch:undo` Patch undo executed. + +```javascript +editor.on('patch:undo', (patch) => { ... }); +``` + +* `patch:redo` Patch redo executed. + +```javascript +editor.on('patch:redo', (patch) => { ... }); +``` + * `load` Editor is loaded. At this stage, the project is loaded in the editor and elements in the canvas are rendered. ```javascript diff --git a/docs/api/selector.md b/docs/api/selector.md index ac3df821a..3c61e9a58 100644 --- a/docs/api/selector.md +++ b/docs/api/selector.md @@ -2,7 +2,7 @@ ## Selector - +**Extends ModelWithPatches** ### Properties diff --git a/packages/core/src/asset_manager/model/Assets.ts b/packages/core/src/asset_manager/model/Assets.ts index 7af0a5649..9d369c371 100644 --- a/packages/core/src/asset_manager/model/Assets.ts +++ b/packages/core/src/asset_manager/model/Assets.ts @@ -4,7 +4,7 @@ import AssetImage from './AssetImage'; import AssetImageView from '../view/AssetImageView'; import TypeableCollection from '../../domain_abstract/model/TypeableCollection'; -export default class Assets extends CollectionWithPatches { +export class Assets extends CollectionWithPatches { constructor(models?: any, options?: any) { super(models, options); } @@ -39,3 +39,5 @@ Assets.prototype.types = [ }, }, ]; + +export default Assets; From bfd98e5714e32e5c2bc708cb6ad6e033641a9f57 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Tue, 20 Jan 2026 14:19:25 +0200 Subject: [PATCH 10/17] fix lint --- .../core/src/asset_manager/model/Assets.ts | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/packages/core/src/asset_manager/model/Assets.ts b/packages/core/src/asset_manager/model/Assets.ts index 9d369c371..897a6ed3f 100644 --- a/packages/core/src/asset_manager/model/Assets.ts +++ b/packages/core/src/asset_manager/model/Assets.ts @@ -5,24 +5,21 @@ import AssetImageView from '../view/AssetImageView'; import TypeableCollection from '../../domain_abstract/model/TypeableCollection'; export class Assets extends CollectionWithPatches { + types: any[] | undefined; + target?: any; + onSelect?: any; + getTypes!: () => any[]; + getType!: (id: string) => any; + getBaseType!: () => any; + recognizeType!: (value: any) => any; + addType!: (id: string, definition: any) => void; + constructor(models?: any, options?: any) { super(models, options); } } -export interface Assets { - types: any[]; - target?: any; - onSelect?: any; - getTypes(): any[]; - getType(id: string): any; - getBaseType(): any; - recognizeType(value: any): any; - addType(id: string, definition: any): void; -} - Object.assign(Assets.prototype, TypeableCollection); - Assets.prototype.types = [ { id: 'image', From 9ee49aecf63df2b44293949733ff94df391641bf Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Tue, 20 Jan 2026 15:19:05 +0200 Subject: [PATCH 11/17] fix linter --- .../core/src/abstract/CollectionWithCategories.ts | 4 +++- packages/core/src/dom_components/index.ts | 8 +------- packages/core/src/selector_manager/index.ts | 11 +++-------- packages/core/src/utils/fractionalIndex.ts | 6 +----- .../patch_manager/collection/CollectionWithPatches.js | 10 ++++++++-- 5 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/core/src/abstract/CollectionWithCategories.ts b/packages/core/src/abstract/CollectionWithCategories.ts index c5d640100..cd4ad5bf5 100644 --- a/packages/core/src/abstract/CollectionWithCategories.ts +++ b/packages/core/src/abstract/CollectionWithCategories.ts @@ -11,7 +11,9 @@ interface ModelWithCategoryProps { const CATEGORY_KEY = 'category'; -export abstract class CollectionWithCategories> extends CollectionWithPatches { +export abstract class CollectionWithCategories< + T extends Model, +> extends CollectionWithPatches { abstract getCategories(): Categories; initCategory(model: T) { diff --git a/packages/core/src/dom_components/index.ts b/packages/core/src/dom_components/index.ts index 8fc0cd147..468e7f345 100644 --- a/packages/core/src/dom_components/index.ts +++ b/packages/core/src/dom_components/index.ts @@ -364,13 +364,7 @@ export default class ComponentManager extends ItemManagerModule { 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'] }] }; + 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 }); From e9ab92bf9fb35c214ab681e6565815b3fe216bee Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Thu, 22 Jan 2026 16:18:34 +0200 Subject: [PATCH 12/17] update --- .../src/patch_manager/ModelWithPatches.ts | 53 +++++++++-- packages/core/src/patch_manager/index.ts | 6 ++ packages/core/src/patch_manager/registry.ts | 91 +++++++++++++++++++ .../patch_manager/model/ModelWithPatches.js | 33 ++++++- .../core/test/specs/patch_manager/registry.js | 33 +++++++ 5 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/patch_manager/registry.ts create mode 100644 packages/core/test/specs/patch_manager/registry.js diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts index 2a1926490..7a8abdbf4 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(); @@ -46,6 +46,16 @@ const syncDraftToState = (draft: any, target: any) => { }); }; +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 ModelWithPatches extends Model { em?: EditorModel; patchObjectType?: string; @@ -56,21 +66,48 @@ export default class ModelWithPatches(args); + + const existingUid = this.get('uid' as any) as string | number | undefined; + const hasExistingUid = isValidPatchUid(existingUid); + const incomingUid = (rawAttrs as any).uid; + + // UID is immutable: ignore any attempt to change/unset it via public `set` + const attrs = hasExistingUid && 'uid' in (rawAttrs as any) ? (({ uid: _uid, ...rest }) => rest)(rawAttrs as any) : 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(attrs as any, opts as any); + } + + const uid = hasExistingUid ? existingUid : isValidPatchUid(incomingUid) ? incomingUid : pm.createId(); + + // Ensure UID exists before taking snapshots to avoid recording it inside patches + if (!hasExistingUid && isValidPatchUid(uid)) { + super.set({ uid } as any, { silent: true }); } - const { attrs, opts } = normalizeSetArgs(args); + // Never track UID mutations via patches + const attrsNoUid = 'uid' in (attrs as any) ? (({ uid: _uid, ...rest }) => rest)(attrs as any) : attrs; + + const objectId = this.getPatchObjectId(); + + if (!isValidPatchUid(objectId)) { + return super.set(attrsNoUid as any, opts as any); + } const beforeState = serialize(this.attributes || {}); - const result = super.set(attrs as any, opts as any); + const result = super.set(attrsNoUid as any, opts as any); const afterState = serialize(this.attributes || {}); const [, patches, inversePatches] = produceWithPatches(beforeState, (draft: any) => { syncDraftToState(draft, afterState); diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index 6206c2cd9..f765b3205 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -65,6 +65,10 @@ export default class PatchManager { this.applyHandler = options.applyPatch; } + createId(): string { + return createPatchId(); + } + createOrGetCurrentPatch(): PatchProps { if (!this.shouldRecord()) { return this.createVoidPatch(); @@ -226,3 +230,5 @@ export default class PatchManager { this.emitter?.trigger?.(event, payload); } } + +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/test/specs/patch_manager/model/ModelWithPatches.js b/packages/core/test/specs/patch_manager/model/ModelWithPatches.js index 1f0c8de02..63aeaea9b 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 }, ); @@ -89,4 +91,25 @@ describe('ModelWithPatches', () => { expect(model.get('foo')).toBe('applied'); 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'); + }); +}); + From 37a8d81bba5fa59a506c163efe748c06cdf0a1d0 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Thu, 22 Jan 2026 17:13:20 +0200 Subject: [PATCH 13/17] update --- .../src/patch_manager/ModelWithPatches.ts | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts index 7a8abdbf4..bd179c6d2 100644 --- a/packages/core/src/patch_manager/ModelWithPatches.ts +++ b/packages/core/src/patch_manager/ModelWithPatches.ts @@ -56,6 +56,16 @@ const createStableUid = () => { 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; +}; + export default class ModelWithPatches extends Model { em?: EditorModel; patchObjectType?: string; @@ -80,41 +90,42 @@ export default class ModelWithPatches rest)(rawAttrs as any) : rawAttrs; + const immutableAttrs = hasExistingUid ? stripUid(rawAttrs) : rawAttrs; const pm = this.patchManager; if (!pm) { - return super.set(attrs as any, opts as any); + return super.set(immutableAttrs as any, opts as any); } - const uid = hasExistingUid ? existingUid : isValidPatchUid(incomingUid) ? incomingUid : pm.createId(); + // Never accept UID mutations via public `set` while tracking patches + const attrsNoUid = stripUid(immutableAttrs); - // Ensure UID exists before taking snapshots to avoid recording it inside patches + 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 }); + super.set({ uid } as any, { silent: true } as any); } - // Never track UID mutations via patches - const attrsNoUid = 'uid' in (attrs as any) ? (({ uid: _uid, ...rest }) => rest)(attrs as any) : attrs; - - const objectId = this.getPatchObjectId(); - - if (!isValidPatchUid(objectId)) { + if (!isValidPatchUid(uid)) { return super.set(attrsNoUid as any, opts as any); } - const beforeState = serialize(this.attributes || {}); + 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 prefix: PatchPath = [this.patchObjectType as string, uid, 'attributes']; const activePatch = pm.createOrGetCurrentPatch(); activePatch.changes.push(...normalizePatchPaths(patches, prefix)); activePatch.reverseChanges.push(...normalizePatchPaths(inversePatches, prefix)); From f8c1964deff7f2d6e652879d0582a520c2f866ff Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Thu, 22 Jan 2026 19:27:23 +0200 Subject: [PATCH 14/17] update logic --- .../src/dom_components/model/Component.ts | 154 ++++++----- .../src/dom_components/model/Components.ts | 186 ++++++++++++- .../domain_abstract/model/StyleableModel.ts | 22 +- .../patch_manager/CollectionWithPatches.ts | 39 +++ .../src/patch_manager/ModelWithPatches.ts | 74 ++++- packages/core/src/patch_manager/index.ts | 2 +- packages/core/src/utils/fractionalIndex.ts | 222 +++++++++++++++ .../test/specs/patch_manager/components.js | 252 ++++++++++++++++++ 8 files changed, 876 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/patch_manager/CollectionWithPatches.ts create mode 100644 packages/core/src/utils/fractionalIndex.ts create mode 100644 packages/core/test/specs/patch_manager/components.js diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 5261ac01c..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,6 +159,22 @@ type GetComponentStyleOpts = GetStyleOpts & { * @module docsjs.Component */ export default class Component extends StyleableModel { + patchObjectType = 'components'; + + protected getPatchExcludedPaths(): PatchPath[] { + return [ + ['toolbar'], + ['traits'], + ['status'], + ['open'], + ['delegate'], + ['_undoexc'], + ['dataResolverWatchers'], + ['attributes', 'class'], + // Structural changes are tracked via `componentsOrder` + ['components'], + ]; + } /** * @private * @ts-ignore */ @@ -292,75 +309,84 @@ export default class Component extends StyleableModel { 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(); } } @@ -1017,7 +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.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 f62832eaf..f6f3d841c 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -1,10 +1,13 @@ import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore'; import Component, { SetAttrOptions } from './Component'; -import { AddOptions, Collection } from '../../common'; +import { AddOptions } from '../../common'; import { DomComponentsConfig } from '../config/config'; import EditorModel from '../../editor/model/Editor'; import ComponentManager from '..'; import CssRule from '../../css_composer/model/CssRule'; +import CollectionWithPatches from '../../patch_manager/CollectionWithPatches'; +import { generateKeyBetween, generateNKeysBetween } from '../../utils/fractionalIndex'; +import { serialize } from '../../utils/mixins'; import { ComponentAdd, @@ -121,7 +124,23 @@ interface AddComponentOptions extends AddOptions { keepIds?: string[]; } -export default class Components extends Collection { + 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 { @@ -132,11 +151,13 @@ Component> { parent?: Component; constructor(models: any, opt: ComponentsOptions) { - super(models, opt); + super(models, { ...opt, em: opt.em, patchObjectType: 'components' }); this.opt = opt; this.listenTo(this, 'add', this.onAdd); + this.listenTo(this, 'remove', this.handlePatchRemove); this.listenTo(this, 'remove', this.removeChildren); this.listenTo(this, 'reset', this.resetChildren); + this.listenTo(this, 'add', this.handlePatchAdd); const { em, config } = opt; this.config = config; this.em = em; @@ -147,6 +168,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 36005186f..0468efa9d 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -1,8 +1,8 @@ import { isArray, isObject, isString, keys } from 'underscore'; -import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common'; +import { ObjectAny, ObjectHash, SetOptions } from '../../common'; import ParserHtml from '../../parser/model/ParserHtml'; import Selectors from '../../selector_manager/model/Selectors'; -import { shallowDiff } from '../../utils/mixins'; +import { createId, shallowDiff } from '../../utils/mixins'; import EditorModel from '../../editor/model/Editor'; import CssRuleView from '../../css_composer/view/CssRuleView'; import ComponentView from '../../dom_components/view/ComponentView'; @@ -13,6 +13,7 @@ import { DataCollectionStateMap } from '../../data_sources/model/data_collection import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWatcher'; import { DataResolverProps } from '../../data_sources/types'; import { _StringKey } from 'backbone'; +import ModelWithPatches from '../../patch_manager/ModelWithPatches'; export type StyleProps = Record; @@ -44,7 +45,17 @@ type WithDataResolvers = { [P in keyof T]?: T[P] | DataResolverProps; }; -export default class StyleableModel extends Model { +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; @@ -312,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 new file mode 100644 index 000000000..3e5bb496f --- /dev/null +++ b/packages/core/src/patch_manager/CollectionWithPatches.ts @@ -0,0 +1,39 @@ +import EditorModel from '../editor/model/Editor'; +import { AddOptions, Collection, Model, RemoveOptions } from '../common'; +import PatchManager from './index'; + +export interface CollectionWithPatchesOptions extends AddOptions { + em?: EditorModel; + patchObjectType?: string; +} + +const isValidPatchUid = (uid: any): uid is string | number => { + if (typeof uid === 'string') return uid !== ''; + return typeof uid === 'number'; +}; + +export default class CollectionWithPatches extends Collection { + em?: EditorModel; + patchObjectType?: string; + + constructor(models?: any, options: CollectionWithPatchesOptions = {}) { + super(models, options); + this.em = options.em; + this.patchObjectType = options.patchObjectType; + } + + protected get patchManager(): PatchManager | undefined { + const pm = (this.em as any)?.Patches as PatchManager | undefined; + return pm?.isEnabled && this.patchObjectType ? pm : undefined; + } + + protected getModelUid(model: T): string | number | undefined { + const uid = (model as any)?.get?.('uid'); + return isValidPatchUid(uid) ? uid : undefined; + } + + protected shouldHandleRemoval(_model: T, opts?: RemoveOptions): boolean { + return !(opts as any)?.temporary; + } +} + diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts index bd179c6d2..e3b90c1bb 100644 --- a/packages/core/src/patch_manager/ModelWithPatches.ts +++ b/packages/core/src/patch_manager/ModelWithPatches.ts @@ -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,16 @@ 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; + } }); }; @@ -66,10 +108,29 @@ const stripUid = (attrs: Partial): 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; + }); +}; + export default class ModelWithPatches extends Model { em?: EditorModel; patchObjectType?: string; + protected getPatchExcludedPaths(): PatchPath[] { + return []; + } + protected get patchManager(): PatchManager | undefined { const pm = (this.em as any)?.Patches as PatchManager | undefined; return pm?.isEnabled && this.patchObjectType ? pm : undefined; @@ -124,11 +185,16 @@ export default class ModelWithPatches(cb: () => T): T { + withSuppressedTracking(cb: () => T): T { const prevSuppress = this.suppressTracking; this.suppressTracking = true; diff --git a/packages/core/src/utils/fractionalIndex.ts b/packages/core/src/utils/fractionalIndex.ts new file mode 100644 index 000000000..bdd13e495 --- /dev/null +++ b/packages/core/src/utils/fractionalIndex.ts @@ -0,0 +1,222 @@ +// Based on rocicorp/fractional-indexing (CC0) + +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)]; +} + 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..2cae7db55 --- /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.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.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.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.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.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); + }); +}); From 3c9b831415f60475313d3b76349c7ad8341e2842 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Sun, 25 Jan 2026 13:16:27 +0200 Subject: [PATCH 15/17] fix build --- packages/core/src/domain_abstract/model/StyleableModel.ts | 5 ++++- packages/core/src/patch_manager/ModelWithPatches.ts | 4 +--- packages/core/src/patch_manager/registry.ts | 1 - packages/core/test/specs/patch_manager/registry.js | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 0468efa9d..3b3fca192 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -55,7 +55,10 @@ const createStableUid = () => { return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId(); }; -export default class StyleableModel extends ModelWithPatches { +export default class StyleableModel extends ModelWithPatches< + T, + UpdateStyleOptions +> { em?: EditorModel; views: StyleableView[] = []; dataResolverWatchers: ModelDataResolverWatchers; diff --git a/packages/core/src/patch_manager/ModelWithPatches.ts b/packages/core/src/patch_manager/ModelWithPatches.ts index e3b90c1bb..305152bf1 100644 --- a/packages/core/src/patch_manager/ModelWithPatches.ts +++ b/packages/core/src/patch_manager/ModelWithPatches.ts @@ -109,9 +109,7 @@ const stripUid = (attrs: Partial): Partial => { }; const isPatchPathExcluded = (path: PatchPath, exclusions: PatchPath[]) => - exclusions.some((excludedPath) => - excludedPath.every((excludedSeg, index) => path[index] === excludedSeg), - ); + exclusions.some((excludedPath) => excludedPath.every((excludedSeg, index) => path[index] === excludedSeg)); const filterExcludedPatches = (patches: PatchChangeProps[], exclusions: PatchPath[]) => { if (!exclusions.length || !patches.length) return patches; diff --git a/packages/core/src/patch_manager/registry.ts b/packages/core/src/patch_manager/registry.ts index 198d6363d..683dc43ed 100644 --- a/packages/core/src/patch_manager/registry.ts +++ b/packages/core/src/patch_manager/registry.ts @@ -88,4 +88,3 @@ export const createRegistryApplyPatchHandler = (registry: PatchObjectsRegistry): }); }; }; - diff --git a/packages/core/test/specs/patch_manager/registry.js b/packages/core/test/specs/patch_manager/registry.js index 5bc6d7ea5..fdf9609e8 100644 --- a/packages/core/test/specs/patch_manager/registry.js +++ b/packages/core/test/specs/patch_manager/registry.js @@ -30,4 +30,3 @@ describe('PatchObjectsRegistry', () => { expect(model.get('foo')).toBe('baz'); }); }); - From a544a26b0993fc9a7a7162aba1990de79615c289 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Sun, 25 Jan 2026 18:16:50 +0200 Subject: [PATCH 16/17] fix format --- packages/core/src/patch_manager/CollectionWithPatches.ts | 1 - packages/core/src/utils/fractionalIndex.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/core/src/patch_manager/CollectionWithPatches.ts b/packages/core/src/patch_manager/CollectionWithPatches.ts index 3e5bb496f..fa4e07a84 100644 --- a/packages/core/src/patch_manager/CollectionWithPatches.ts +++ b/packages/core/src/patch_manager/CollectionWithPatches.ts @@ -36,4 +36,3 @@ export default class CollectionWithPatches extends Coll return !(opts as any)?.temporary; } } - diff --git a/packages/core/src/utils/fractionalIndex.ts b/packages/core/src/utils/fractionalIndex.ts index bdd13e495..15e76f32e 100644 --- a/packages/core/src/utils/fractionalIndex.ts +++ b/packages/core/src/utils/fractionalIndex.ts @@ -219,4 +219,3 @@ export function generateNKeysBetween( const c = generateKeyBetween(a, b, digits); return [...generateNKeysBetween(a, c, mid, digits), c, ...generateNKeysBetween(c, b, n - mid - 1, digits)]; } - From b47341a204a5d45c4c1970745c2e67ad79263146 Mon Sep 17 00:00:00 2001 From: IhorKaleniuk666 Date: Sun, 25 Jan 2026 18:54:57 +0200 Subject: [PATCH 17/17] fix format --- packages/core/src/patch_manager/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index b6a7ca333..84230258a 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -98,7 +98,9 @@ export default class PatchManager { if (!collection) return; const type = collection.patchObjectType; const id = - typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : collection.collectionId; + typeof collection.getPatchCollectionId === 'function' + ? collection.getPatchCollectionId() + : collection.collectionId; if (!type || !isValidPatchUid(id)) return; const idStr = String(id); this.trackedCollections[type] = this.trackedCollections[type] || {}; @@ -109,7 +111,9 @@ export default class PatchManager { if (!collection) return; const type = collection.patchObjectType; const id = - typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : collection.collectionId; + 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];