mirror of https://github.com/artf/grapesjs.git
8 changed files with 876 additions and 75 deletions
@ -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<T extends Model = Model> extends Collection<T> { |
|||
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; |
|||
} |
|||
} |
|||
|
|||
@ -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)]; |
|||
} |
|||
|
|||
@ -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); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue