mirror of https://github.com/artf/grapesjs.git
17 changed files with 920 additions and 164 deletions
@ -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<T = any> { |
|||
private byType: Record<string, Map<PatchUid, T>> = {}; |
|||
|
|||
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<string, PatchGroup>(); |
|||
|
|||
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); |
|||
}); |
|||
}; |
|||
}; |
|||
|
|||
@ -0,0 +1,252 @@ |
|||
import { applyPatches } from 'immer'; |
|||
import Component from 'dom_components/model/Component'; |
|||
import Editor from 'editor/model/Editor'; |
|||
import PatchManager from 'patch_manager'; |
|||
import { generateKeyBetween, generateNKeysBetween } from 'utils/fractionalIndex'; |
|||
import { serialize } from 'utils/mixins'; |
|||
|
|||
const flush = () => Promise.resolve(); |
|||
|
|||
const getUpdatePatches = (events) => events.filter((e) => e.event === 'patch:update').map((e) => e.payload); |
|||
|
|||
const initState = (models) => ({ |
|||
components: Object.fromEntries( |
|||
models.map((model) => { |
|||
const attributes = serialize(model.toJSON()); |
|||
delete attributes.components; |
|||
return [model.get('uid'), { attributes }]; |
|||
}), |
|||
), |
|||
}); |
|||
|
|||
describe('Patch tracking: nested Components order', () => { |
|||
let em; |
|||
let compOpts; |
|||
|
|||
beforeEach(() => { |
|||
em = new Editor({ avoidDefaults: true, avoidInlineStyle: true }); |
|||
em.Pages.onLoad(); |
|||
const domc = em.Components; |
|||
compOpts = { em, componentTypes: domc.componentTypes, domc }; |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
em.destroyAll(); |
|||
}); |
|||
|
|||
test('Does not create patches for non-storable props (toolbar/traits/status)', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => events.push({ event, payload }) }, |
|||
}); |
|||
const cmp = new Component({}, compOpts); |
|||
em.set('Patches', pm); |
|||
events.length = 0; |
|||
|
|||
cmp.set('toolbar', [{ command: 'tlb-move' }]); |
|||
cmp.set('traits', [{ type: 'text', name: 'title' }]); |
|||
cmp.set('status', 'selected'); |
|||
|
|||
await flush(); |
|||
|
|||
expect(getUpdatePatches(events)).toHaveLength(0); |
|||
}); |
|||
|
|||
test('Add child: records component add + order-map add patches', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => events.push({ event, payload }) }, |
|||
}); |
|||
pm.createId = () => 'child-1'; |
|||
|
|||
const parent = new Component({}, compOpts); |
|||
parent.set('uid', 'parent'); |
|||
em.set('Patches', pm); |
|||
parent.components().setParent(parent); |
|||
events.length = 0; |
|||
|
|||
parent.append({ tagName: 'div' }); |
|||
await flush(); |
|||
|
|||
const patches = getUpdatePatches(events); |
|||
expect(patches).toHaveLength(1); |
|||
|
|||
const patch = patches[0]; |
|||
expect(patch.changes).toHaveLength(2); |
|||
expect(patch.reverseChanges).toHaveLength(2); |
|||
|
|||
expect(patch.changes[0]).toMatchObject({ |
|||
op: 'add', |
|||
path: ['components', 'child-1'], |
|||
}); |
|||
expect(patch.changes[0].value.attributes.uid).toBe('child-1'); |
|||
|
|||
expect(patch.changes[1]).toEqual({ |
|||
op: 'add', |
|||
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)], |
|||
value: 'child-1', |
|||
}); |
|||
|
|||
// Undo order must remove map entry first, then the component object.
|
|||
expect(patch.reverseChanges[0]).toEqual({ |
|||
op: 'remove', |
|||
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)], |
|||
}); |
|||
expect(patch.reverseChanges[1]).toEqual({ |
|||
op: 'remove', |
|||
path: ['components', 'child-1'], |
|||
}); |
|||
}); |
|||
|
|||
test('Remove child: records order-map remove + component remove patches', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => events.push({ event, payload }) }, |
|||
}); |
|||
pm.createId = () => 'child-1'; |
|||
|
|||
const parent = new Component({}, compOpts); |
|||
parent.set('uid', 'parent'); |
|||
em.set('Patches', pm); |
|||
parent.components().setParent(parent); |
|||
|
|||
const [child] = parent.append({ tagName: 'div' }); |
|||
await flush(); |
|||
events.length = 0; |
|||
|
|||
parent.components().remove(child); |
|||
await flush(); |
|||
|
|||
const patches = getUpdatePatches(events); |
|||
expect(patches).toHaveLength(1); |
|||
|
|||
const patch = patches[0]; |
|||
expect(patch.changes).toHaveLength(2); |
|||
expect(patch.reverseChanges).toHaveLength(2); |
|||
|
|||
const orderKey = generateKeyBetween(null, null); |
|||
expect(patch.changes[0]).toEqual({ |
|||
op: 'remove', |
|||
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey], |
|||
}); |
|||
expect(patch.changes[1]).toEqual({ |
|||
op: 'remove', |
|||
path: ['components', 'child-1'], |
|||
}); |
|||
|
|||
// Undo order must re-add the component object first, then restore the order map.
|
|||
expect(patch.reverseChanges[0]).toMatchObject({ |
|||
op: 'add', |
|||
path: ['components', 'child-1'], |
|||
}); |
|||
expect(patch.reverseChanges[0].value.attributes.uid).toBe('child-1'); |
|||
expect(patch.reverseChanges[1]).toEqual({ |
|||
op: 'add', |
|||
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey], |
|||
value: 'child-1', |
|||
}); |
|||
}); |
|||
|
|||
test('Reorder within same parent updates only componentsOrder (no array index moves)', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => events.push({ event, payload }) }, |
|||
}); |
|||
|
|||
const parent = new Component({}, compOpts); |
|||
parent.set('uid', 'parent'); |
|||
|
|||
const [c1, c2, c3] = parent.append([{ tagName: 'div' }, { tagName: 'span' }, { tagName: 'p' }]); |
|||
c1.set('uid', 'c1'); |
|||
c2.set('uid', 'c2'); |
|||
c3.set('uid', 'c3'); |
|||
|
|||
em.set('Patches', pm); |
|||
parent.components().setParent(parent); |
|||
events.length = 0; |
|||
|
|||
// Move c1 to the end (temporary remove + re-add).
|
|||
parent.components().remove(c1, { temporary: true }); |
|||
parent.components().add(c1, { at: 2 }); |
|||
await flush(); |
|||
|
|||
const patches = getUpdatePatches(events); |
|||
expect(patches).toHaveLength(1); |
|||
|
|||
const patch = patches[0]; |
|||
expect(patch.changes).toHaveLength(2); |
|||
expect(patch.reverseChanges).toHaveLength(2); |
|||
|
|||
const [k1, k2, k3] = generateNKeysBetween(null, null, 3); |
|||
const newKey = generateKeyBetween(k3, null); |
|||
|
|||
expect(patch.changes[0]).toEqual({ |
|||
op: 'remove', |
|||
path: ['components', 'parent', 'attributes', 'componentsOrder', k1], |
|||
}); |
|||
expect(patch.changes[1]).toEqual({ |
|||
op: 'add', |
|||
path: ['components', 'parent', 'attributes', 'componentsOrder', newKey], |
|||
value: 'c1', |
|||
}); |
|||
|
|||
// Regression: no patches for `attributes.components` array indices.
|
|||
const hasComponentsArrayPatch = patch.changes.some((ch) => { |
|||
const p = ch.path || []; |
|||
for (let i = 0; i < p.length - 1; i++) { |
|||
if (p[i] === 'attributes' && p[i + 1] === 'components') return true; |
|||
} |
|||
return false; |
|||
}); |
|||
expect(hasComponentsArrayPatch).toBe(false); |
|||
}); |
|||
|
|||
test('Move between parents updates order maps and is undo/redo deterministic', async () => { |
|||
const events = []; |
|||
let state; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => events.push({ event, payload }) }, |
|||
applyPatch: (changes) => { |
|||
state = applyPatches(state, changes); |
|||
}, |
|||
}); |
|||
|
|||
const parentA = new Component({}, compOpts); |
|||
const parentB = new Component({}, compOpts); |
|||
parentA.set('uid', 'parentA'); |
|||
parentB.set('uid', 'parentB'); |
|||
|
|||
const [child] = parentA.append({ tagName: 'div' }); |
|||
child.set('uid', 'c1'); |
|||
|
|||
em.set('Patches', pm); |
|||
parentA.components().setParent(parentA); |
|||
parentB.components().setParent(parentB); |
|||
|
|||
state = initState([parentA, parentB, child]); |
|||
const before = JSON.parse(JSON.stringify(state)); |
|||
events.length = 0; |
|||
|
|||
parentA.components().remove(child, { temporary: true }); |
|||
parentB.components().add(child, { at: 0 }); |
|||
await flush(); |
|||
|
|||
const patches = getUpdatePatches(events); |
|||
expect(patches).toHaveLength(1); |
|||
|
|||
const patch = patches[0]; |
|||
state = applyPatches(state, patch.changes); |
|||
const after = JSON.parse(JSON.stringify(state)); |
|||
|
|||
pm.undo(); |
|||
expect(state).toEqual(before); |
|||
|
|||
pm.redo(); |
|||
expect(state).toEqual(after); |
|||
}); |
|||
}); |
|||
@ -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'); |
|||
}); |
|||
}); |
|||
|
|||
Loading…
Reference in new issue