diff --git a/packages/core/package.json b/packages/core/package.json index 838879f5d..93b9171a8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,7 +35,9 @@ "backbone-undo": "0.2.6", "codemirror": "5.63.0", "codemirror-formatting": "1.0.0", + "fractional-indexing": "^3.2.0", "html-entities": "~1.4.0", + "immer": "^10.2.0", "promise-polyfill": "8.3.0", "underscore": "1.13.1" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c485269d1..70abe62f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -160,6 +160,13 @@ export type { ExpressionProps, } from './data_sources/model/conditional_variables/DataCondition'; export type { default as PatchManager } from './patch_manager'; -export type { PatchProps, PatchManagerConfig, JsonPatch } from './patch_manager/types'; +export type { + PatchProps, + PatchManagerConfig, + JsonPatch, + PatchAdapter, + PatchAdapterEvent, + PatchAdapterChange, +} from './patch_manager/types'; export default grapesjs; diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts index f2f241960..583f7eca0 100644 --- a/packages/core/src/patch_manager/index.ts +++ b/packages/core/src/patch_manager/index.ts @@ -8,9 +8,18 @@ import { Collection } from '../common'; import EditorModel from '../editor/model/Editor'; import { EditorEvents } from '../editor/types'; import { createId } from '../utils/mixins'; -import type { JsonPatch, PatchManagerConfig, PatchProps } from './types'; +import { enablePatches, produceWithPatches } from 'immer'; +import type { + JsonPatch, + PatchAdapter, + PatchAdapterChange, + PatchAdapterEvent, + PatchManagerConfig, + PatchProps, +} from './types'; const encodePointer = (segment: string) => segment.replace(/~/g, '~0').replace(/\//g, '~1'); +enablePatches(); export default class PatchManager extends ItemManagerModule { storageKey = ''; @@ -26,6 +35,10 @@ export default class PatchManager extends ItemManagerModule { private coalesceMs = 0; private maxHistory = 500; private isApplyingExternal = false; + private adapters = new Map>(); + private adapterListeners: { adapter: string; target: any; event: string; handler: (...args: any[]) => void }[] = []; + private trackingBound = false; + private fractionalGen?: (a: string | null, b: string | null) => string; private internalSetOptions = { fromUndo: true, @@ -44,6 +57,7 @@ export default class PatchManager extends ItemManagerModule { const cfg = (this.getConfig() as any) ?? {}; const normalized = typeof cfg === 'boolean' ? { enable: cfg } : cfg; this.init({ enable: true, ...normalized }); + this.registerDefaultAdapters(); this.setupTracking(); } @@ -55,22 +69,99 @@ export default class PatchManager extends ItemManagerModule { return this; } + private registerDefaultAdapters() { + this.registerAdapter(this.createComponentAdapter()); + this.registerAdapter(this.createCssRuleAdapter()); + } + + registerAdapter(adapter: PatchAdapter) { + const normalized = this.normalizeAdapter(adapter); + this.unbindAdapter(normalized.type); + this.adapters.set(normalized.type, normalized); + + if (this.trackingBound) { + this.bindAdapter(normalized); + } + + return this; + } + + private normalizeAdapter(adapter: PatchAdapter): PatchAdapter { + if (adapter.blockedKeys && !(adapter.blockedKeys instanceof Set)) { + adapter.blockedKeys = new Set(adapter.blockedKeys); + } + return adapter; + } + + private bindAdapters() { + if (this.trackingBound) return; + this.trackingBound = true; + this.adapters.forEach((adapter) => this.bindAdapter(adapter)); + } + + private bindAdapter(adapter: PatchAdapter) { + adapter.events?.forEach((event) => this.bindAdapterEvent(adapter, event)); + if (this.isReady) { + adapter.onReady?.(this); + } + } + + private bindAdapterEvent(adapter: PatchAdapter, event: PatchAdapterEvent) { + const target = event.target ? event.target(this) : this.em; + if (!target?.on) return; + const listener = (...args: any[]) => { + const options = event.getOptions?.(...args) ?? this.extractOptions(args); + const skipTracking = !event.skipTrackingCheck && (!this.canTrack() || this.shouldSkipOptions(options)); + if (skipTracking) return; + const result = event.handler({ args, options }); + if (result?.patches?.length) { + this.collect(result.patches, result.inverse || []); + } + }; + + target.on(event.event, listener); + this.adapterListeners.push({ adapter: adapter.type, target, event: event.event, handler: listener }); + } + + private extractOptions(args: any[]) { + const last = args[args.length - 1]; + const beforeLast = args[args.length - 2]; + if (last && typeof last === 'object') return last; + if (beforeLast && typeof beforeLast === 'object') return beforeLast; + } + + private unbindAdapter(type: string) { + const listeners = this.adapterListeners.filter((item) => item.adapter === type); + listeners.forEach(({ target, event, handler }) => target?.off?.(event, handler)); + this.adapterListeners = this.adapterListeners.filter((item) => item.adapter !== type); + } + + private unbindAllAdapters() { + this.adapterListeners.forEach(({ target, event, handler }) => target?.off?.(event, handler)); + this.adapterListeners = []; + this.trackingBound = false; + } + + private notifyAdaptersReady() { + if (!this.isReady) return; + this.adapters.forEach((adapter) => adapter.onReady?.(this)); + } + private setupTracking() { const { em } = this; - this.cssRules = em.Css?.getAll?.(); - this.ensureAllCssRuleIds(); - this.cssRules?.on('add', this.handleCssRuleAdd); this.isReady = !!em.get('readyLoad'); + this.ensureAllCssRuleIds(); + this.bindAdapters(); + this.notifyAdaptersReady(); em.on('change:readyLoad', this.handleReadyLoad); em.on(EditorEvents.projectLoad, this.handleProjectLoad); - em.on(ComponentsEvents.add, this.handleComponentAdd); - em.on(ComponentsEvents.remove, this.handleComponentRemove); } private handleReadyLoad = () => { if (!this.em.get('readyLoad')) return; this.isReady = true; this.ensureAllCssRuleIds(); + this.notifyAdaptersReady(); this.resetHistory(); this.em.off('change:readyLoad', this.handleReadyLoad); }; @@ -78,60 +169,62 @@ export default class PatchManager extends ItemManagerModule { private handleProjectLoad = () => { this.resetHistory(); this.ensureAllCssRuleIds(); + this.notifyAdaptersReady(); }; handleChange(data: Record = {}, opts: Record = {}) { if (!this.canTrack() || this.shouldSkipOptions(opts)) return; const patches: JsonPatch[] = []; const reverse: JsonPatch[] = []; - const component = data.component as Component | undefined; - const changed = data.changed as Record | undefined; - const rule = data.rule as CssRule | undefined; - if (component && changed) { - this.handleComponentChange(component, changed, patches, reverse); - } else if (rule && changed) { - this.handleRuleChange(rule, changed, patches, reverse); + this.adapters.forEach((adapter) => { + const change = this.getChangeFromData(adapter, data); + change && this.handleAdapterChange(adapter, change, patches, reverse); + }); + + patches.length && this.collect(patches, reverse); + } + + private getChangeFromData(adapter: PatchAdapter, data: Record): PatchAdapterChange | null { + if (adapter.getChange) { + return adapter.getChange(data); } + const { sourceKeys = [] } = adapter; + const changed = data.changed as Record | undefined; + if (!changed) return null; - if (patches.length) { - this.collect(patches, reverse); + for (const key of sourceKeys) { + const target = data[key] as T | undefined; + if (target) { + return { target, changed }; + } } + + return null; } - private handleComponentChange( - component: Component, - changed: Record, + private handleAdapterChange( + adapter: PatchAdapter, + change: PatchAdapterChange, patches: JsonPatch[], reverse: JsonPatch[], ) { - const compId = component.getId(); - Object.keys(changed).forEach((key) => { - if (this.isBlockedRootKey(key)) return; - const path = this.buildPath('component', compId, [key]); - const nextVal = changed[key]; - const prevVal = component.previous ? component.previous(key) : undefined; - const { patch, inverse } = this.buildPatchPair(path, prevVal, nextVal); - patch && patches.push(patch); - inverse && reverse.push(inverse); - }); - } - - private handleRuleChange(rule: CssRule, changed: Record, patches: JsonPatch[], reverse: JsonPatch[]) { - const ruleId = this.ensureCssRuleId(rule); + const { target, changed } = change; + const id = adapter.getId(target); + if (!id) return; Object.keys(changed).forEach((key) => { - const path = this.buildPath('cssRule', `${ruleId}`, [key]); - const nextVal = changed[key]; - const prevVal = rule.previous ? rule.previous(key) : undefined; - const { patch, inverse } = this.buildPatchPair(path, prevVal, nextVal); - patch && patches.push(patch); - inverse && reverse.push(inverse); + const filter = adapter.filterChangedKey; + if (filter ? !filter(key) : this.isBlockedKey(key, adapter)) return; + const nextVal = this.cloneValue(changed[key]); + const prevVal = (target as any)?.previous ? (target as any).previous(key) : undefined; + const pair = this.buildImmerPatchPair(adapter, `${id}`, key, prevVal, nextVal); + patches.push(...pair.patches); + reverse.push(...pair.inverse); }); } private handleComponentAdd = (component: Component, opts: any = {}) => { - if (!this.canTrack() || this.shouldSkipOptions(opts)) return; const parent = component.parent(); const collection = (component.collection || parent?.components()) as Components | undefined; if (!parent || !collection) return; @@ -143,11 +236,10 @@ export default class PatchManager extends ItemManagerModule { const value = this.cloneValue(component.toJSON()); const patch: JsonPatch = { op: 'add', path, value }; const inverse: JsonPatch = { op: 'remove', path }; - this.collect([patch], [inverse]); + return { patches: [patch], inverse: [inverse] }; }; private handleComponentRemove = (component: Component, opts: any = {}) => { - if (!this.canTrack() || this.shouldSkipOptions(opts)) return; const collection = (opts.collection || component.prevColl) as Components | undefined; const parent = component.parent({ prev: true }); if (!parent || !collection) return; @@ -159,9 +251,64 @@ export default class PatchManager extends ItemManagerModule { const reverseVal = this.cloneValue(component.toJSON()); const patch: JsonPatch = { op: 'remove', path }; const inverse: JsonPatch = { op: 'add', path, value: reverseVal }; - this.collect([patch], [inverse]); + return { patches: [patch], inverse: [inverse] }; }; + private createComponentAdapter(): PatchAdapter { + return { + type: 'component', + sourceKeys: ['component'], + blockedKeys: PatchManager.blockedRootKeys, + getId: (component) => component.getId(), + resolve: (em, id) => em.Components?.getById(id), + events: [ + { + event: ComponentsEvents.add, + getOptions: (...args: any[]) => args[1], + handler: ({ args }) => this.handleComponentAdd(args[0] as Component, args[1]), + }, + { + event: ComponentsEvents.remove, + getOptions: (...args: any[]) => args[1], + handler: ({ args }) => this.handleComponentRemove(args[0] as Component, args[1]), + }, + ], + applyPatch: (target, path, patch) => { + if (path[0] === 'components') { + return this.applyComponentsPatch(target, path.slice(1), patch); + } + return false; + }, + }; + } + + private createCssRuleAdapter(): PatchAdapter { + return { + type: 'cssRule', + sourceKeys: ['rule'], + getChange: (data) => { + const rule = data.rule as CssRule | undefined; + const changed = data.changed as Record | undefined; + if (!rule || !changed) return null; + this.ensureCssRuleId(rule); + return { target: rule, changed }; + }, + getId: (rule) => this.ensureCssRuleId(rule), + resolve: (em, id) => em.Css?.rules?.get(id) ?? em.Css?.get(id), + events: [ + { + event: 'add', + target: () => this.getCssRules(), + handler: ({ args }) => { + this.ensureCssRuleId(args[0] as CssRule); + }, + skipTrackingCheck: true, + }, + ], + onReady: () => this.ensureAllCssRuleIds(), + }; + } + private cloneValue(value: any) { if (typeof value === 'undefined') return value; try { @@ -171,22 +318,29 @@ export default class PatchManager extends ItemManagerModule { } } - private buildPatchPair(path: string, prevVal: any, nextVal: any) { - const op = this.getOp(prevVal, nextVal); - const invOp = this.getOp(nextVal, prevVal); - const patch = op ? this.buildPatch(op, path, nextVal) : null; - const inverse = invOp ? this.buildPatch(invOp, path, prevVal) : null; - return { patch, inverse }; - } + private buildImmerPatchPair(adapter: PatchAdapter, id: string, key: string, prevVal: any, nextVal: any) { + const base = { value: this.cloneValue(prevVal) }; + const [, forward, backward] = produceWithPatches(base, (draft) => { + (draft as any).value = this.cloneValue(nextVal); + }); - private buildPatch(op: JsonPatch['op'], path: string, value: any): JsonPatch { - const cloned = this.cloneValue(value); - return op === 'remove' ? { op, path } : { op, path, value: cloned }; + return { + patches: this.toJsonPatches(adapter, id, key, forward), + inverse: this.toJsonPatches(adapter, id, key, backward), + }; } - private getOp(prevVal: any, nextVal: any): JsonPatch['op'] | null { - if (typeof nextVal === 'undefined') return 'remove'; - return typeof prevVal === 'undefined' ? 'add' : 'replace'; + private toJsonPatches(adapter: PatchAdapter, id: string, key: string, list: any[] = []) { + return list + .map((patch) => { + const pathArr: (string | number)[] = Array.isArray(patch.path) ? patch.path : []; + const [, ...rest] = pathArr; // drop synthetic "value" root + const fullPath = this.buildPath(adapter.type, `${id}`, [key, ...rest]); + if (!fullPath) return null; + const value = typeof patch.value === 'undefined' ? undefined : this.cloneValue(patch.value); + return patch.op === 'remove' ? { op: patch.op, path: fullPath } : { op: patch.op, path: fullPath, value }; + }) + .filter(Boolean) as JsonPatch[]; } private buildPath(type: string, id: string, segments: (string | number)[] = []) { @@ -195,17 +349,78 @@ export default class PatchManager extends ItemManagerModule { } private getComponentKey(coll?: Components, cmp?: Component, at?: number) { + if (!coll) return '0'; const getFractional = (coll as any)?.getFractionalKey; + const supportsFractional = this.supportsFractionalIndexing(coll); + + if (supportsFractional) { + const key = this.buildFractionalKey(coll, typeof at === 'number' ? at : cmp ? coll.indexOf(cmp) : undefined); + if (key) return key; + } + if (typeof getFractional === 'function' && cmp) { return getFractional.call(coll, cmp); } + if (typeof at === 'number') { return `${at}`; } - const idx = coll && cmp ? coll.indexOf(cmp) : -1; + const idx = cmp ? coll.indexOf(cmp) : -1; return `${idx >= 0 ? idx : 0}`; } + private supportsFractionalIndexing(coll: any) { + return ( + typeof coll?.findByFractionalKey === 'function' || + typeof coll?.setFractionalKey === 'function' || + typeof coll?.getIndexFromFractionalKey === 'function' + ); + } + + private buildFractionalKey(coll: any, at?: number) { + const gen = this.getGenerateKeyBetween(); + if (!gen) return ''; + const index = typeof at === 'number' ? at : coll?.length || 0; + const prev = index > 0 ? coll.at(index - 1) : null; + const next = index < coll.length ? coll.at(index) : null; + const prevKey = this.getExistingFractionalKey(coll, prev); + const nextKey = this.getExistingFractionalKey(coll, next); + return gen(prevKey || null, nextKey || null) || ''; + } + + private getExistingFractionalKey(coll: any, model: any) { + if (!model) return null; + const getter = coll?.getFractionalKey; + const key = + (typeof getter === 'function' && getter.call(coll, model)) || + (model as any)?.fractionalKey || + (typeof model?.get === 'function' ? model.get('fractionalKey') : undefined); + return key || null; + } + + private setFractionalKey(coll: any, model: any, key: string) { + if (!key || !model) return; + if (typeof coll?.setFractionalKey === 'function') { + coll.setFractionalKey(model, key); + } else { + (model as any).fractionalKey = key; + typeof model?.set === 'function' && model.set('fractionalKey', key, this.internalSetOptions); + } + } + + // Lazy-load fractional-indexing to work in CJS/Jest environments without extra transpilation. + private getGenerateKeyBetween() { + if (this.fractionalGen) return this.fractionalGen; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('fractional-indexing'); + this.fractionalGen = mod?.generateKeyBetween || mod?.default?.generateKeyBetween || mod?.default; + } catch (err) { + this.fractionalGen = undefined; + } + return this.fractionalGen; + } + private resetHistory() { this.coalesceTimer && clearTimeout(this.coalesceTimer); this.coalesceTimer = undefined; @@ -344,7 +559,8 @@ export default class PatchManager extends ItemManagerModule { private applyJsonPatch(p: JsonPatch) { const seg = p.path.split('/').filter(Boolean); const [objectType, objectId, ...rest] = seg; - const target = this.resolveTarget(objectType, objectId); + const adapter = objectType ? this.adapters.get(objectType) : null; + const target = objectType && objectId && adapter ? adapter.resolve(this.em, objectId) : null; if (this.debug) { console.log('[PatchManager] applyJsonPatch', { @@ -352,24 +568,24 @@ export default class PatchManager extends ItemManagerModule { objectType, objectId, rest, + adapter: adapter?.type, resolved: !!target, }); } - if (!objectType || !objectId) return; - if (!target) return; + if (!objectType || !objectId || !adapter || !target) return; - if (rest[0] === 'components' && this.applyComponentsPatch(target, rest.slice(1), p)) { + if (adapter.applyPatch && adapter.applyPatch(target, rest, p)) { return; } switch (p.op) { case 'add': case 'replace': - this.setByPath(target, rest, p.value); + this.setByPath(target, rest, p.value, adapter); break; case 'remove': - this.deleteByPath(target, rest); + this.deleteByPath(target, rest, adapter); break; case 'move': this.handleMove(target, seg, p); @@ -398,7 +614,7 @@ export default class PatchManager extends ItemManagerModule { if (patch.value) { const added = coll.add(patch.value as any, opts); const list = Array.isArray(added) ? added : [added]; - list.forEach((m) => coll.setFractionalKey?.(m, key)); + list.forEach((m) => this.setFractionalKey(coll, m, key)); } return true; } @@ -416,9 +632,12 @@ export default class PatchManager extends ItemManagerModule { private findComponentByKey(coll: any, key: string) { if (!coll) return null; - if (typeof coll.findByFractionalKey === 'function') { + if (typeof coll.findByFractionalKey === 'function' && isNaN(Number(key))) { return coll.findByFractionalKey(key); } + if (isNaN(Number(key))) { + return coll.find((m: any) => this.getExistingFractionalKey(coll, m) === key) || null; + } const idx = Number(key); return Number.isNaN(idx) ? null : coll.at(idx); } @@ -428,7 +647,9 @@ export default class PatchManager extends ItemManagerModule { return coll.getIndexFromFractionalKey(key); } const idx = Number(key); - return Number.isNaN(idx) ? coll.length : idx; + if (!Number.isNaN(idx)) return idx; + const model = this.findComponentByKey(coll, key); + return model ? coll.indexOf(model) : coll.length; } private applyComponentsMove(coll: any, key: string, patch: JsonPatch) { @@ -449,20 +670,13 @@ export default class PatchManager extends ItemManagerModule { const at = this.resolveComponentIndex(coll, key); const added = coll.add(model, { ...this.internalSetOptions, at }); const list = Array.isArray(added) ? added : [added]; - list.forEach((m) => coll.setFractionalKey?.(m, key)); + list.forEach((m) => this.setFractionalKey(coll, m, key)); return true; } private resolveTarget(type: string, id: string): any { - const { em } = this; - switch (type) { - case 'component': - return em.Components?.getById(id); - case 'cssRule': - return em.Css?.rules?.get(id) ?? em.Css?.get(id); - default: - return null; - } + const adapter = this.adapters.get(type); + return adapter ? adapter.resolve(this.em, id) : null; } private ensureCssRuleId(rule?: CssRule) { @@ -485,27 +699,27 @@ export default class PatchManager extends ItemManagerModule { return ruleId; } - private handleCssRuleAdd = (rule: CssRule) => { - this.ensureCssRuleId(rule); - }; - - private ensureAllCssRuleIds() { + private getCssRules() { if (!this.cssRules) { this.cssRules = this.em.Css?.getAll?.(); - this.cssRules?.on('add', this.handleCssRuleAdd); } - this.cssRules?.each((rule: CssRule) => this.ensureCssRuleId(rule)); + return this.cssRules; + } + + private ensureAllCssRuleIds() { + this.getCssRules()?.each((rule: CssRule) => this.ensureCssRuleId(rule)); } - private isBlockedRootKey(key?: string) { + private isBlockedKey(key?: string, adapter?: PatchAdapter) { if (!key) return false; - return PatchManager.blockedRootKeys.has(key); + const blocked = adapter?.blockedKeys as Set | undefined; + return !!blocked?.has(key); } - private setByPath(target: any, path: string[], value: any) { + private setByPath(target: any, path: string[], value: any, adapter?: PatchAdapter) { if (!target || !path.length) return; const rootKey = path[0]; - if (this.isBlockedRootKey(rootKey)) return; + if (this.isBlockedKey(rootKey, adapter)) return; if (typeof target.set === 'function') { if (path.length === 1) { @@ -548,10 +762,10 @@ export default class PatchManager extends ItemManagerModule { ref[path[path.length - 1]] = value; } - private deleteByPath(target: any, path: string[]) { + private deleteByPath(target: any, path: string[], adapter?: PatchAdapter) { if (!target || !path.length) return; const rootKey = path[0]; - if (this.isBlockedRootKey(rootKey)) return; + if (this.isBlockedKey(rootKey, adapter)) return; if (typeof target.unset === 'function' && path.length === 1) { target.unset(rootKey, this.internalSetOptions); @@ -569,11 +783,9 @@ export default class PatchManager extends ItemManagerModule { private handleMove(_target: any, _seg: string[], _p: JsonPatch) {} destroy(): void { - this.cssRules?.off('add', this.handleCssRuleAdd); + this.unbindAllAdapters(); this.em?.off('change:readyLoad', this.handleReadyLoad); this.em?.off(EditorEvents.projectLoad, this.handleProjectLoad); - this.em?.off(ComponentsEvents.add, this.handleComponentAdd); - this.em?.off(ComponentsEvents.remove, this.handleComponentRemove); this.resetHistory(); this.isApplyingExternal = false; super.__destroy?.(); diff --git a/packages/core/src/patch_manager/types.ts b/packages/core/src/patch_manager/types.ts index c1c0b0f6b..a6483c5c5 100644 --- a/packages/core/src/patch_manager/types.ts +++ b/packages/core/src/patch_manager/types.ts @@ -21,3 +21,39 @@ export interface PatchManagerConfig { coalesceMs?: number; debug?: boolean; } + +export interface PatchAdapterChange { + target: T; + changed: Record; +} + +export interface PatchAdapterEventContext { + args: any[]; + options?: Record; +} + +export interface PatchAdapterEventResult { + patches?: JsonPatch[]; + inverse?: JsonPatch[]; +} + +export interface PatchAdapterEvent { + event: string; + handler: (context: PatchAdapterEventContext) => PatchAdapterEventResult | void; + getOptions?: (...args: any[]) => Record | undefined; + skipTrackingCheck?: boolean; + target?: (manager: any) => any; +} + +export interface PatchAdapter { + type: string; + sourceKeys?: string[]; + getChange?: (data: Record) => PatchAdapterChange | null; + getId: (target: T) => string | undefined; + resolve: (em: any, id: string) => T | undefined | null; + filterChangedKey?: (key: string) => boolean; + events?: PatchAdapterEvent[]; + applyPatch?: (target: T, path: string[], patch: JsonPatch) => boolean | void; + onReady?: (manager: any) => void; + blockedKeys?: Set | string[]; +} diff --git a/packages/core/test/specs/patch_manager/index.ts b/packages/core/test/specs/patch_manager/index.ts index e221440d9..f35a34746 100644 --- a/packages/core/test/specs/patch_manager/index.ts +++ b/packages/core/test/specs/patch_manager/index.ts @@ -1,8 +1,9 @@ import Editor from '../../../src/editor'; import PatchManager from '../../../src/patch_manager'; -import { PatchProps } from '../../../src/patch_manager/types'; +import { PatchAdapter, PatchProps } from '../../../src/patch_manager/types'; import Component from '../../../src/dom_components/model/Component'; import EditorModel from '../../../src/editor/model/Editor'; +import { Model } from '../../../src/common'; import { setupTestEditor } from '../../common'; describe('Patch Manager', () => { @@ -84,4 +85,67 @@ describe('Patch Manager', () => { expect(wrapper.components()).toHaveLength(1); expect(wrapper.components().at(0).get('content')).toBe('from patch'); }); + + test('collects css rule changes with adapter', () => { + const updates: PatchProps[] = []; + editor.on('patch:update', ({ patch }) => updates.push(patch)); + + const rule = editor.Css.addRules('.test { color: red; }')[0]; + updates.length = 0; + + rule.setStyle({ color: 'blue' }); + + expect(updates).toHaveLength(1); + const [patch] = updates; + const ruleId = (rule as any).id || rule.get('id'); + expect(ruleId).toBeTruthy(); + expect(patch.changes[0]).toMatchObject({ + op: 'replace', + path: `/cssRule/${ruleId}/style`, + }); + expect((patch.changes[0] as any).value?.color).toBe('blue'); + expect(patch.reverseChanges[0]).toMatchObject({ + op: 'replace', + path: `/cssRule/${ruleId}/style`, + }); + expect((patch.reverseChanges[0] as any).value?.color).toBe('red'); + + patches.undo(); + expect(rule.getStyle().color).toBe('red'); + + patches.redo(); + expect(rule.getStyle().color).toBe('blue'); + }); + + test('allows registering custom adapters without touching core', () => { + const updates: PatchProps[] = []; + editor.on('patch:update', ({ patch }) => updates.push(patch)); + + const custom = new Model({ id: 'custom-1', value: 'one' }); + const adapter: PatchAdapter = { + type: 'custom', + sourceKeys: ['custom'], + getId: (model) => model.get('id') as string, + resolve: (_em: EditorModel, id: string) => (id === custom.get('id') ? custom : null), + }; + + patches.registerAdapter(adapter); + + custom.set('value', 'two'); + const changed = custom.changedAttributes() || {}; + em.changesUp({}, { custom, changed }); + + expect(updates).toHaveLength(1); + const changePatch = updates[0]; + expect(changePatch.changes[0]).toMatchObject({ + op: 'replace', + path: `/custom/${custom.get('id')}/value`, + value: 'two', + }); + expect(changePatch.reverseChanges[0]).toMatchObject({ + op: 'replace', + path: `/custom/${custom.get('id')}/value`, + value: 'one', + }); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfc3ce63b..56ff6be9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -235,9 +235,15 @@ importers: codemirror-formatting: specifier: 1.0.0 version: 1.0.0 + fractional-indexing: + specifier: ^3.2.0 + version: 3.2.0 html-entities: specifier: ~1.4.0 version: 1.4.0 + immer: + specifier: ^10.2.0 + version: 10.2.0 promise-polyfill: specifier: 8.3.0 version: 8.3.0 @@ -4183,6 +4189,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fragment-cache@0.2.1: resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==} engines: {node: '>=0.10.0'} @@ -4731,6 +4741,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==} @@ -13766,6 +13779,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@3.2.0: {} + fragment-cache@0.2.1: dependencies: map-cache: 0.2.2 @@ -14438,6 +14453,8 @@ snapshots: immediate@3.3.0: {} + immer@10.2.0: {} + immutable@4.3.7: {} import-cwd@2.1.0: