Browse Source

update

pull/6673/head
Kaleniuk 2 months ago
parent
commit
302a4b8dfd
  1. 2
      packages/core/package.json
  2. 9
      packages/core/src/index.ts
  3. 394
      packages/core/src/patch_manager/index.ts
  4. 36
      packages/core/src/patch_manager/types.ts
  5. 66
      packages/core/test/specs/patch_manager/index.ts
  6. 17
      pnpm-lock.yaml

2
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"
},

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

394
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<string, PatchAdapter<any>>();
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<T>(adapter: PatchAdapter<T>) {
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<T>(adapter: PatchAdapter<T>): PatchAdapter<T> {
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<any>) {
adapter.events?.forEach((event) => this.bindAdapterEvent(adapter, event));
if (this.isReady) {
adapter.onReady?.(this);
}
}
private bindAdapterEvent(adapter: PatchAdapter<any>, 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<string, any> = {}, opts: Record<string, any> = {}) {
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<string, any> | 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<T>(adapter: PatchAdapter<T>, data: Record<string, any>): PatchAdapterChange<T> | null {
if (adapter.getChange) {
return adapter.getChange(data);
}
const { sourceKeys = [] } = adapter;
const changed = data.changed as Record<string, any> | 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<string, any>,
private handleAdapterChange<T>(
adapter: PatchAdapter<T>,
change: PatchAdapterChange<T>,
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<string, any>, 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<Component> {
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<CssRule> {
return {
type: 'cssRule',
sourceKeys: ['rule'],
getChange: (data) => {
const rule = data.rule as CssRule | undefined;
const changed = data.changed as Record<string, any> | 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<any>, 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<any>, 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<any>) {
if (!key) return false;
return PatchManager.blockedRootKeys.has(key);
const blocked = adapter?.blockedKeys as Set<string> | undefined;
return !!blocked?.has(key);
}
private setByPath(target: any, path: string[], value: any) {
private setByPath(target: any, path: string[], value: any, adapter?: PatchAdapter<any>) {
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<any>) {
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?.();

36
packages/core/src/patch_manager/types.ts

@ -21,3 +21,39 @@ export interface PatchManagerConfig {
coalesceMs?: number;
debug?: boolean;
}
export interface PatchAdapterChange<T = any> {
target: T;
changed: Record<string, any>;
}
export interface PatchAdapterEventContext {
args: any[];
options?: Record<string, any>;
}
export interface PatchAdapterEventResult {
patches?: JsonPatch[];
inverse?: JsonPatch[];
}
export interface PatchAdapterEvent {
event: string;
handler: (context: PatchAdapterEventContext) => PatchAdapterEventResult | void;
getOptions?: (...args: any[]) => Record<string, any> | undefined;
skipTrackingCheck?: boolean;
target?: (manager: any) => any;
}
export interface PatchAdapter<T = any> {
type: string;
sourceKeys?: string[];
getChange?: (data: Record<string, any>) => PatchAdapterChange<T> | 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> | string[];
}

66
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<Model> = {
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',
});
});
});

17
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:

Loading…
Cancel
Save