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