mirror of https://github.com/artf/grapesjs.git
35 changed files with 1065 additions and 51 deletions
@ -1,6 +1,12 @@ |
|||
import { Collection } from '../../common'; |
|||
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches'; |
|||
import Device from './Device'; |
|||
|
|||
export default class Devices extends Collection<Device> {} |
|||
export default class Devices extends CollectionWithPatches<Device> { |
|||
patchObjectType = 'devices'; |
|||
|
|||
constructor(models?: any, opts: any = {}) { |
|||
super(models, { ...opts, patchObjectType: 'devices', collectionId: opts.collectionId || 'global' } as any); |
|||
} |
|||
} |
|||
|
|||
Devices.prototype.model = Device; |
|||
|
|||
@ -0,0 +1,305 @@ |
|||
import { generateNKeysBetween } from '../utils/fractionalIndex'; |
|||
import { Collection, Model, AddOptions } from '../common'; |
|||
import EditorModel from '../editor/model/Editor'; |
|||
import PatchManager, { PatchChangeProps, PatchPath } from './index'; |
|||
|
|||
export interface CollectionWithPatchesOptions extends AddOptions { |
|||
em?: EditorModel; |
|||
collectionId?: string; |
|||
patchObjectType?: string; |
|||
} |
|||
|
|||
export type FractionalEntry<T extends Model = Model> = { |
|||
id: string; |
|||
key: string; |
|||
model?: T | undefined; |
|||
}; |
|||
|
|||
type PendingRemoval = { |
|||
oldKey: string; |
|||
patch: any; |
|||
change: PatchChangeProps; |
|||
reverse: PatchChangeProps; |
|||
}; |
|||
|
|||
export default class CollectionWithPatches<T extends Model = Model> extends Collection<T> { |
|||
em?: EditorModel; |
|||
collectionId?: string; |
|||
patchObjectType?: string; |
|||
private fractionalMap: Record<string, string> = {}; |
|||
private pendingRemovals: Record<string, PendingRemoval> = {}; |
|||
private suppressSortRebuild = false; |
|||
private isResetting = false; |
|||
|
|||
constructor(models?: any, options: CollectionWithPatchesOptions = {}) { |
|||
super(models, options); |
|||
this.em = options.em; |
|||
this.collectionId = options.collectionId; |
|||
this.patchObjectType = options.patchObjectType; |
|||
this.on('sort', this.handleSort, this); |
|||
this.rebuildFractionalMap(false); |
|||
|
|||
// Ensure tracking/registry works for apply(external) in enabled mode.
|
|||
Promise.resolve().then(() => { |
|||
const pm = this.patchManager; |
|||
if (pm?.isEnabled) { |
|||
pm.trackCollection?.(this as any); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
// Ensure models created via collection.add/reset get a reference to `em`.
|
|||
// This is critical for patch tracking and for apply(external) routing.
|
|||
// @ts-ignore
|
|||
_prepareModel(attrs: any, options: any) { |
|||
const nextOptions = options ? { ...options } : {}; |
|||
this.em && nextOptions.em == null && (nextOptions.em = this.em); |
|||
// @ts-ignore
|
|||
return Collection.prototype._prepareModel.call(this, attrs, nextOptions); |
|||
} |
|||
|
|||
get patchManager(): PatchManager | undefined { |
|||
return this.em?.Patches; |
|||
} |
|||
|
|||
setCollectionId(id: string) { |
|||
this.collectionId = id; |
|||
} |
|||
|
|||
add(models: any, options?: CollectionWithPatchesOptions) { |
|||
const result = super.add(models, options); |
|||
!this.isResetting && this.assignKeysForMissingModels(); |
|||
return result; |
|||
} |
|||
|
|||
remove(...args: any[]) { |
|||
const removed = super.remove(...args); |
|||
const removedModels = Array.isArray(removed) ? removed : removed ? [removed] : []; |
|||
removedModels.forEach((model) => { |
|||
const id = this.getModelId(model as any); |
|||
if (!id) return; |
|||
const oldKey = this.fractionalMap[id]; |
|||
if (oldKey == null) return; |
|||
|
|||
delete this.fractionalMap[id]; |
|||
const pending = this.recordFractionalPatch(id, undefined, oldKey); |
|||
if (pending) { |
|||
this.pendingRemovals[id] = pending; |
|||
Promise.resolve().then(() => { |
|||
// Cleanup in case it was not re-added in the same tick.
|
|||
if (this.pendingRemovals[id]) { |
|||
delete this.pendingRemovals[id]; |
|||
} |
|||
}); |
|||
} |
|||
}); |
|||
|
|||
return removed; |
|||
} |
|||
|
|||
reset(models?: any, options?: CollectionWithPatchesOptions) { |
|||
this.isResetting = true; |
|||
try { |
|||
const result = super.reset(models, options); |
|||
this.fractionalMap = {}; |
|||
this.pendingRemovals = {}; |
|||
this.rebuildFractionalMap(); |
|||
return result; |
|||
} finally { |
|||
this.isResetting = false; |
|||
} |
|||
} |
|||
|
|||
protected handleSort(_collection?: any, options: any = {}) { |
|||
if (this.suppressSortRebuild || options?.fromPatches) return; |
|||
this.rebuildFractionalMap(); |
|||
} |
|||
|
|||
protected getPatchCollectionId(): string | undefined { |
|||
return this.collectionId || this.cid; |
|||
} |
|||
|
|||
protected rebuildFractionalMap(record: boolean = true) { |
|||
const ids = this.models.map((model) => this.getModelId(model)).filter(Boolean); |
|||
const keys = ids.length ? generateNKeysBetween(null, null, ids.length) : []; |
|||
const prevMap = { ...this.fractionalMap }; |
|||
const nextMap: Record<string, string> = {}; |
|||
|
|||
ids.forEach((id, index) => { |
|||
const key = keys[index]; |
|||
nextMap[id] = key; |
|||
if (record) { |
|||
this.recordFractionalPatch(id, key, prevMap[id]); |
|||
} |
|||
}); |
|||
|
|||
if (record) { |
|||
Object.keys(prevMap).forEach((id) => { |
|||
if (!(id in nextMap)) { |
|||
this.recordFractionalPatch(id, undefined, prevMap[id]); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
this.fractionalMap = nextMap; |
|||
} |
|||
|
|||
protected assignKeysForMissingModels() { |
|||
let idx = 0; |
|||
const models = this.models; |
|||
|
|||
while (idx < models.length) { |
|||
const model = models[idx]; |
|||
const id = this.getModelId(model); |
|||
|
|||
if (!id || this.fractionalMap[id]) { |
|||
idx++; |
|||
continue; |
|||
} |
|||
|
|||
const segmentIds: string[] = []; |
|||
const segmentStartIdx = idx; |
|||
|
|||
while (idx < models.length) { |
|||
const segId = this.getModelId(models[idx]); |
|||
if (!segId || this.fractionalMap[segId]) break; |
|||
segmentIds.push(segId); |
|||
idx++; |
|||
} |
|||
|
|||
// Find previous and next keys around the segment, based on current collection order.
|
|||
let prevKey: string | null = null; |
|||
for (let i = segmentStartIdx - 1; i >= 0; i--) { |
|||
const prevId = this.getModelId(models[i]); |
|||
if (prevId && this.fractionalMap[prevId]) { |
|||
prevKey = this.fractionalMap[prevId]; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
let nextKey: string | null = null; |
|||
for (let i = idx; i < models.length; i++) { |
|||
const nextId = this.getModelId(models[i]); |
|||
if (nextId && this.fractionalMap[nextId]) { |
|||
nextKey = this.fractionalMap[nextId]; |
|||
break; |
|||
} |
|||
} |
|||
|
|||
const keys = generateNKeysBetween(prevKey, nextKey, segmentIds.length); |
|||
segmentIds.forEach((segId, i) => { |
|||
const newKey = keys[i]; |
|||
this.fractionalMap[segId] = newKey; |
|||
|
|||
const pending = this.pendingRemovals[segId]; |
|||
if (pending) { |
|||
this.removeRecordedPatch(pending); |
|||
delete this.pendingRemovals[segId]; |
|||
this.recordFractionalPatch(segId, newKey, pending.oldKey); |
|||
} else { |
|||
this.recordFractionalPatch(segId, newKey, undefined); |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
|
|||
protected getModelId(model: T): string { |
|||
if (!model) return ''; |
|||
if (typeof (model as any).getId === 'function') { |
|||
const id = (model as any).getId(); |
|||
const valid = typeof id === 'string' ? id !== '' : typeof id === 'number'; |
|||
return valid ? String(id) : ''; |
|||
} |
|||
const id = (model as any).get?.('id'); |
|||
return (id as string) || model.cid || ''; |
|||
} |
|||
|
|||
protected recordFractionalPatch(id: string, newKey?: string, oldKey?: string): PendingRemoval | void { |
|||
const pm = this.patchManager; |
|||
const objectType = this.patchObjectType; |
|||
const collectionId = this.getPatchCollectionId(); |
|||
if (!pm || !pm.isEnabled || !objectType || !collectionId) return; |
|||
if (newKey === oldKey) return; |
|||
|
|||
const path: PatchPath = [objectType, collectionId, 'order', id]; |
|||
let change: PatchChangeProps; |
|||
let reverse: PatchChangeProps; |
|||
|
|||
if (newKey === undefined) { |
|||
change = { op: 'remove', path }; |
|||
reverse = { op: 'add', path, value: oldKey }; |
|||
} else if (oldKey === undefined) { |
|||
change = { op: 'add', path, value: newKey }; |
|||
reverse = { op: 'remove', path }; |
|||
} else { |
|||
change = { op: 'replace', path, value: newKey }; |
|||
reverse = { op: 'replace', path, value: oldKey }; |
|||
} |
|||
|
|||
const patch = pm.createOrGetCurrentPatch(); |
|||
patch.changes.push(change); |
|||
// Reverse changes should be applied in reverse order.
|
|||
patch.reverseChanges.unshift(reverse); |
|||
|
|||
if (newKey === undefined && oldKey != null) { |
|||
return { oldKey, patch, change, reverse }; |
|||
} |
|||
} |
|||
|
|||
getAndSortFractionalMap(): FractionalEntry<T>[] { |
|||
return Object.entries(this.fractionalMap) |
|||
.sort(([idA, keyA], [idB, keyB]) => keyA.localeCompare(keyB) || idA.localeCompare(idB)) |
|||
.map(([id, key]) => ({ id, key, model: this.getModelByPatchId(id) })); |
|||
} |
|||
|
|||
getOrderKey(id: string) { |
|||
return this.fractionalMap[id]; |
|||
} |
|||
|
|||
applyOrderKeyPatch(id: string, op: PatchChangeProps['op'], value?: string) { |
|||
if (!id) return; |
|||
|
|||
if (op === 'remove') { |
|||
delete this.fractionalMap[id]; |
|||
const model = this.getModelByPatchId(id); |
|||
model && Collection.prototype.remove.call(this, model); |
|||
return; |
|||
} |
|||
|
|||
if (op === 'add' || op === 'replace') { |
|||
if (value == null) return; |
|||
this.fractionalMap[id] = value; |
|||
this.sortByFractionalOrder(); |
|||
} |
|||
} |
|||
|
|||
protected sortByFractionalOrder() { |
|||
const entries = this.getAndSortFractionalMap(); |
|||
const sorted = entries.map((e) => e.model).filter(Boolean) as T[]; |
|||
if (!sorted.length) return; |
|||
|
|||
const included = new Set(sorted.map((m) => m.cid)); |
|||
const leftovers = this.models.filter((m) => !included.has(m.cid)); |
|||
const nextModels = [...sorted, ...leftovers]; |
|||
|
|||
this.suppressSortRebuild = true; |
|||
try { |
|||
this.models.splice(0, this.models.length, ...nextModels); |
|||
this.trigger('sort', this, { fromPatches: true }); |
|||
} finally { |
|||
this.suppressSortRebuild = false; |
|||
} |
|||
} |
|||
|
|||
private removeRecordedPatch(pending: PendingRemoval) { |
|||
const patch = pending.patch; |
|||
const changeIdx = patch?.changes?.indexOf?.(pending.change); |
|||
if (changeIdx >= 0) patch.changes.splice(changeIdx, 1); |
|||
const reverseIdx = patch?.reverseChanges?.indexOf?.(pending.reverse); |
|||
if (reverseIdx >= 0) patch.reverseChanges.splice(reverseIdx, 1); |
|||
} |
|||
|
|||
private getModelByPatchId(id: string): T | undefined { |
|||
return this.models.find((model) => this.getModelId(model) === id); |
|||
} |
|||
} |
|||
@ -0,0 +1,226 @@ |
|||
// License: CC0 (no rights reserved).
|
|||
// See https://github.com/rocicorp/fractional-indexing
|
|||
|
|||
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,198 @@ |
|||
import PatchManager from 'patch_manager'; |
|||
import CollectionWithPatches from 'patch_manager/CollectionWithPatches'; |
|||
import { Model } from 'common'; |
|||
|
|||
class TestModel extends Model { |
|||
getId() { |
|||
return this.get('id'); |
|||
} |
|||
} |
|||
|
|||
class TestCollection extends CollectionWithPatches { |
|||
patchObjectType = 'test-collection'; |
|||
} |
|||
|
|||
describe('CollectionWithPatches', () => { |
|||
test('records order changes and sorts models after inserts', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { |
|||
trigger: (event, payload) => events.push({ event, payload }), |
|||
}, |
|||
}); |
|||
const em = { Patches: pm }; |
|||
const coll = new TestCollection([], { em, collectionId: 'root' }); |
|||
|
|||
coll.add(new TestModel({ id: 'a' })); |
|||
coll.add(new TestModel({ id: 'b' })); |
|||
coll.add(new TestModel({ id: 'c' }), { at: 1 }); |
|||
|
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
|
|||
const sortedIds = coll.getAndSortFractionalMap().map((entry) => entry.id); |
|||
expect(sortedIds).toEqual(['a', 'c', 'b']); |
|||
|
|||
const updateEvents = events.filter((item) => item.event === 'patch:update'); |
|||
expect(updateEvents).toHaveLength(1); |
|||
const payload = updateEvents[updateEvents.length - 1].payload; |
|||
const prefix = ['test-collection', 'root']; |
|||
const matchesPrefix = payload.changes.every((change) => |
|||
prefix.every((segment, index) => change.path[index] === segment), |
|||
); |
|||
expect(matchesPrefix).toBe(true); |
|||
}); |
|||
|
|||
test('move within the same collection generates replace and supports undo/redo', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { |
|||
trigger: (event, payload) => events.push({ event, payload }), |
|||
}, |
|||
}); |
|||
const em = { Patches: pm }; |
|||
const coll = new TestCollection([], { em, collectionId: 'root' }); |
|||
|
|||
coll.add(new TestModel({ id: 'a' })); |
|||
coll.add(new TestModel({ id: 'b' })); |
|||
coll.add(new TestModel({ id: 'c' })); |
|||
|
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
events.length = 0; |
|||
|
|||
const modelC = coll.get('c'); |
|||
coll.remove(modelC); |
|||
coll.add(modelC, { at: 1 }); |
|||
|
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
|
|||
const movedIds = coll.getAndSortFractionalMap().map((entry) => entry.id); |
|||
expect(movedIds).toEqual(['a', 'c', 'b']); |
|||
|
|||
const updateEvents = events.filter((item) => item.event === 'patch:update'); |
|||
expect(updateEvents).toHaveLength(1); |
|||
const patch = updateEvents[0].payload; |
|||
|
|||
const moveChanges = patch.changes.filter((c) => c.path[3] === 'c'); |
|||
expect(moveChanges).toHaveLength(1); |
|||
expect(moveChanges[0].op).toBe('replace'); |
|||
|
|||
pm.undo(); |
|||
const undoIds = coll.getAndSortFractionalMap().map((entry) => entry.id); |
|||
expect(undoIds).toEqual(['a', 'b', 'c']); |
|||
|
|||
pm.redo(); |
|||
const redoIds = coll.getAndSortFractionalMap().map((entry) => entry.id); |
|||
expect(redoIds).toEqual(['a', 'c', 'b']); |
|||
}); |
|||
|
|||
test('apply(external) applies order patches without re-logging', async () => { |
|||
const pmAEvents = []; |
|||
const pmA = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => pmAEvents.push({ event, payload }) }, |
|||
}); |
|||
const pmBEvents = []; |
|||
const pmB = new PatchManager({ |
|||
enabled: true, |
|||
emitter: { trigger: (event, payload) => pmBEvents.push({ event, payload }) }, |
|||
}); |
|||
|
|||
const emA = { Patches: pmA }; |
|||
const emB = { Patches: pmB }; |
|||
const collA = new TestCollection([], { em: emA, collectionId: 'root' }); |
|||
const collB = new TestCollection([], { em: emB, collectionId: 'root' }); |
|||
|
|||
['a', 'b', 'c'].forEach((id) => { |
|||
collA.add(new TestModel({ id })); |
|||
collB.add(new TestModel({ id })); |
|||
}); |
|||
|
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
pmAEvents.length = 0; |
|||
pmBEvents.length = 0; |
|||
|
|||
// Produce a patch on A
|
|||
const modelC = collA.get('c'); |
|||
collA.remove(modelC); |
|||
collA.add(modelC, { at: 1 }); |
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
|
|||
const patch = pmAEvents.find((e) => e.event === 'patch:update')?.payload; |
|||
expect(patch).toBeTruthy(); |
|||
|
|||
// Apply patch to B as external (no patch:update expected)
|
|||
pmB.apply(patch, { external: true }); |
|||
|
|||
const idsB = collB.getAndSortFractionalMap().map((entry) => entry.id); |
|||
expect(idsB).toEqual(['a', 'c', 'b']); |
|||
expect(pmBEvents).toHaveLength(0); |
|||
}); |
|||
|
|||
test('fractional order is deterministic under key collisions (concurrent ops)', async () => { |
|||
const pm = new PatchManager({ enabled: true }); |
|||
const em = { Patches: pm }; |
|||
const coll = new TestCollection([], { em, collectionId: 'root' }); |
|||
|
|||
['a', 'b', 'c', 'd'].forEach((id) => coll.add(new TestModel({ id }))); |
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
|
|||
const conflictKey = coll.getOrderKey('b'); |
|||
expect(conflictKey).toBeTruthy(); |
|||
|
|||
const patch1 = { |
|||
id: 'p1', |
|||
changes: [{ op: 'replace', path: ['test-collection', 'root', 'order', 'c'], value: conflictKey }], |
|||
reverseChanges: [], |
|||
}; |
|||
const patch2 = { |
|||
id: 'p2', |
|||
changes: [{ op: 'replace', path: ['test-collection', 'root', 'order', 'd'], value: conflictKey }], |
|||
reverseChanges: [], |
|||
}; |
|||
|
|||
pm.apply(patch1, { external: true }); |
|||
pm.apply(patch2, { external: true }); |
|||
|
|||
const ids1 = coll.getAndSortFractionalMap().map((e) => e.id); |
|||
|
|||
// Reset and apply in reverse order
|
|||
const coll2 = new TestCollection([], { em, collectionId: 'root-2' }); |
|||
['a', 'b', 'c', 'd'].forEach((id) => coll2.add(new TestModel({ id }))); |
|||
await Promise.resolve(); |
|||
await Promise.resolve(); |
|||
pm.trackCollection(coll2); |
|||
|
|||
const patch1b = { ...patch1, changes: [{ ...patch1.changes[0], path: ['test-collection', 'root-2', 'order', 'c'] }] }; |
|||
const patch2b = { ...patch2, changes: [{ ...patch2.changes[0], path: ['test-collection', 'root-2', 'order', 'd'] }] }; |
|||
pm.apply(patch2b, { external: true }); |
|||
pm.apply(patch1b, { external: true }); |
|||
|
|||
const ids2 = coll2.getAndSortFractionalMap().map((e) => e.id); |
|||
expect(ids2).toEqual(ids1); |
|||
}); |
|||
|
|||
test('skips patch recording when disabled', async () => { |
|||
const events = []; |
|||
const pm = new PatchManager({ |
|||
enabled: false, |
|||
emitter: { |
|||
trigger: (event, payload) => events.push({ event, payload }), |
|||
}, |
|||
}); |
|||
const em = { Patches: pm }; |
|||
const coll = new TestCollection([], { em, collectionId: 'root' }); |
|||
|
|||
coll.add(new TestModel({ id: 'x' })); |
|||
await Promise.resolve(); |
|||
|
|||
expect(events).toHaveLength(0); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue