mirror of https://github.com/artf/grapesjs.git
committed by
GitHub
45 changed files with 2445 additions and 117 deletions
@ -1,6 +1,10 @@ |
|||||
import { Collection } from '../../common'; |
import { Collection } from '../../common'; |
||||
import Device from './Device'; |
import Device from './Device'; |
||||
|
|
||||
export default class Devices extends Collection<Device> {} |
export default class Devices extends Collection<Device> { |
||||
|
constructor(models?: any, opts: any = {}) { |
||||
|
super(models, opts); |
||||
|
} |
||||
|
} |
||||
|
|
||||
Devices.prototype.model = Device; |
Devices.prototype.model = Device; |
||||
|
|||||
@ -0,0 +1,323 @@ |
|||||
|
import { generateNKeysBetween } from '../utils/fractionalIndex'; |
||||
|
import { AddOptions, Collection, Model } 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; |
||||
|
trackOrder?: boolean; |
||||
|
} |
||||
|
|
||||
|
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; |
||||
|
private trackOrder = true; |
||||
|
|
||||
|
constructor(models?: any, options: CollectionWithPatchesOptions = {}) { |
||||
|
const nextOptions = { ...options }; |
||||
|
super(models, nextOptions); |
||||
|
this.em = nextOptions.em; |
||||
|
this.collectionId = nextOptions.collectionId; |
||||
|
this.patchObjectType = nextOptions.patchObjectType; |
||||
|
this.trackOrder = nextOptions.trackOrder !== false; |
||||
|
|
||||
|
if (this.trackOrder) { |
||||
|
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; |
||||
|
const id = this.getPatchCollectionId(); |
||||
|
if (pm?.isEnabled && this.patchObjectType && id != null) { |
||||
|
pm.trackCollection?.(this as any); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
get patchManager(): PatchManager | undefined { |
||||
|
const pm = (this.em as any)?.Patches as PatchManager | undefined; |
||||
|
return pm?.isEnabled && this.patchObjectType ? pm : undefined; |
||||
|
} |
||||
|
|
||||
|
setCollectionId(id: string) { |
||||
|
this.collectionId = id; |
||||
|
} |
||||
|
|
||||
|
add(model: T | {}, options?: CollectionWithPatchesOptions): T; |
||||
|
add(models: Array<T | {}>, options?: CollectionWithPatchesOptions): T[]; |
||||
|
add(models: any, options?: CollectionWithPatchesOptions): any { |
||||
|
const result = super.add(models, this.withEmOptions(options) as any); |
||||
|
this.trackOrder && !this.isResetting && this.assignKeysForMissingModels(); |
||||
|
return result as any; |
||||
|
} |
||||
|
|
||||
|
remove(model: T | {}, options?: any): T; |
||||
|
remove(models: Array<T | {}>, options?: any): T[]; |
||||
|
remove(models: any, options?: any): any { |
||||
|
const removed = super.remove(models, options as any); |
||||
|
if (!this.trackOrder) return removed; |
||||
|
|
||||
|
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, this.withEmOptions(options) as any); |
||||
|
if (this.trackOrder) { |
||||
|
this.fractionalMap = {}; |
||||
|
this.pendingRemovals = {}; |
||||
|
this.rebuildFractionalMap(); |
||||
|
} |
||||
|
return result; |
||||
|
} finally { |
||||
|
this.isResetting = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
protected handleSort(_collection?: any, options: any = {}) { |
||||
|
if (!this.trackOrder) return; |
||||
|
if (this.suppressSortRebuild || options?.fromPatches) return; |
||||
|
this.rebuildFractionalMap(); |
||||
|
} |
||||
|
|
||||
|
protected getPatchCollectionId(): string | undefined { |
||||
|
return this.collectionId; |
||||
|
} |
||||
|
|
||||
|
protected withEmOptions(options?: CollectionWithPatchesOptions) { |
||||
|
const nextOptions = options ? { ...options } : {}; |
||||
|
if (this.em && nextOptions.em == null) { |
||||
|
nextOptions.em = this.em; |
||||
|
} |
||||
|
return nextOptions; |
||||
|
} |
||||
|
|
||||
|
protected rebuildFractionalMap(record: boolean = true) { |
||||
|
if (!this.trackOrder) return; |
||||
|
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() { |
||||
|
if (!this.trackOrder) return; |
||||
|
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 as any); |
||||
|
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,216 @@ |
|||||
|
import { enablePatches, produceWithPatches } from 'immer'; |
||||
|
import EditorModel from '../editor/model/Editor'; |
||||
|
import { Model, ObjectHash, SetOptions } from '../common'; |
||||
|
import { createId, serialize } from '../utils/mixins'; |
||||
|
import PatchManager, { PatchChangeProps, PatchPath } from './index'; |
||||
|
|
||||
|
enablePatches(); |
||||
|
|
||||
|
type SetArgs<T> = { |
||||
|
attrs: Partial<T>; |
||||
|
opts: SetOptions; |
||||
|
}; |
||||
|
|
||||
|
const normalizeSetArgs = <T>(args: any[]): SetArgs<T> => { |
||||
|
const [first, second, third] = args; |
||||
|
|
||||
|
if (typeof first === 'string') { |
||||
|
return { |
||||
|
attrs: { [first]: second } as any, |
||||
|
opts: (third || {}) as SetOptions, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
attrs: (first || {}) as Partial<T>, |
||||
|
opts: (second || {}) as SetOptions, |
||||
|
}; |
||||
|
}; |
||||
|
|
||||
|
const normalizePatchPaths = (patches: PatchChangeProps[], prefix: PatchPath): PatchChangeProps[] => |
||||
|
patches.map((patch) => ({ |
||||
|
...patch, |
||||
|
path: [...prefix, ...patch.path], |
||||
|
...(patch.from ? { from: [...prefix, ...patch.from] } : {}), |
||||
|
})); |
||||
|
|
||||
|
const syncDraftToState = (draft: any, target: any) => { |
||||
|
const isObject = (value: any): value is Record<string, any> => |
||||
|
value != null && typeof value === 'object' && !Array.isArray(value); |
||||
|
|
||||
|
if (Array.isArray(draft) && Array.isArray(target)) { |
||||
|
if (draft.length > target.length) { |
||||
|
draft.splice(target.length, draft.length - target.length); |
||||
|
} |
||||
|
|
||||
|
for (let i = 0; i < target.length; i++) { |
||||
|
const draftValue = draft[i]; |
||||
|
const targetValue = target[i]; |
||||
|
|
||||
|
if (Array.isArray(draftValue) && Array.isArray(targetValue)) { |
||||
|
syncDraftToState(draftValue, targetValue); |
||||
|
} else if (isObject(draftValue) && isObject(targetValue)) { |
||||
|
syncDraftToState(draftValue, targetValue); |
||||
|
} else if (draftValue !== targetValue) { |
||||
|
draft[i] = targetValue; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Add new entries (after syncing shared indexes).
|
||||
|
for (let i = draft.length; i < target.length; i++) { |
||||
|
draft.push(target[i]); |
||||
|
} |
||||
|
|
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (!isObject(draft) || !isObject(target)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
Object.keys(draft).forEach((key) => { |
||||
|
if (!(key in target)) { |
||||
|
delete draft[key]; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
Object.keys(target).forEach((key) => { |
||||
|
const draftValue = draft[key]; |
||||
|
const targetValue = target[key]; |
||||
|
|
||||
|
if (Array.isArray(draftValue) && Array.isArray(targetValue)) { |
||||
|
syncDraftToState(draftValue, targetValue); |
||||
|
} else if (isObject(draftValue) && isObject(targetValue)) { |
||||
|
syncDraftToState(draftValue, targetValue); |
||||
|
} else if (draftValue !== targetValue) { |
||||
|
draft[key] = targetValue; |
||||
|
} |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
const isValidPatchUid = (uid: any): uid is string | number => { |
||||
|
if (typeof uid === 'string') return uid !== ''; |
||||
|
return typeof uid === 'number'; |
||||
|
}; |
||||
|
|
||||
|
const createStableUid = () => { |
||||
|
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID; |
||||
|
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId(); |
||||
|
}; |
||||
|
|
||||
|
const stripUid = <T extends ObjectHash>(attrs: Partial<T>): Partial<T> => { |
||||
|
const attrsAny = attrs as any; |
||||
|
if (attrsAny && typeof attrsAny === 'object' && 'uid' in attrsAny) { |
||||
|
const { uid: _uid, ...rest } = attrsAny; |
||||
|
return rest as Partial<T>; |
||||
|
} |
||||
|
|
||||
|
return attrs; |
||||
|
}; |
||||
|
|
||||
|
const isPatchPathExcluded = (path: PatchPath, exclusions: PatchPath[]) => |
||||
|
exclusions.some((excludedPath) => excludedPath.every((excludedSeg, index) => path[index] === excludedSeg)); |
||||
|
|
||||
|
const filterExcludedPatches = (patches: PatchChangeProps[], exclusions: PatchPath[]) => { |
||||
|
if (!exclusions.length || !patches.length) return patches; |
||||
|
return patches.filter((patch) => { |
||||
|
const { path, from } = patch; |
||||
|
if (isPatchPathExcluded(path, exclusions)) return false; |
||||
|
if (from && isPatchPathExcluded(from, exclusions)) return false; |
||||
|
return true; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
export default class ModelWithPatches<T extends ObjectHash = any, S = SetOptions, E = any> extends Model<T, S, E> { |
||||
|
em?: EditorModel; |
||||
|
patchObjectType?: string; |
||||
|
|
||||
|
constructor(attributes?: T, options: any = {}) { |
||||
|
super(attributes as any, options); |
||||
|
options?.em && (this.em = options.em); |
||||
|
|
||||
|
Promise.resolve().then(() => { |
||||
|
const pm = (this.em as any)?.Patches as PatchManager | undefined; |
||||
|
if (pm?.isEnabled && this.patchObjectType) { |
||||
|
pm.trackModel(this as any); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
protected getPatchExcludedPaths(): PatchPath[] { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
protected get patchManager(): PatchManager | undefined { |
||||
|
const pm = (this.em as any)?.Patches as PatchManager | undefined; |
||||
|
if (pm?.isEnabled && this.patchObjectType) { |
||||
|
pm.trackModel(this as any); |
||||
|
return pm; |
||||
|
} |
||||
|
return undefined; |
||||
|
} |
||||
|
|
||||
|
protected getPatchObjectId(): string | number | undefined { |
||||
|
return this.get('uid' as any); |
||||
|
} |
||||
|
|
||||
|
clone(): this { |
||||
|
const attrs = serialize(this.attributes || {}) as any; |
||||
|
attrs.uid = createStableUid(); |
||||
|
return new (this.constructor as any)(attrs); |
||||
|
} |
||||
|
|
||||
|
set(...args: any[]): this { |
||||
|
const { attrs: rawAttrs, opts } = normalizeSetArgs<T>(args); |
||||
|
|
||||
|
const existingUid = this.get('uid' as any) as string | number | undefined; |
||||
|
const hasExistingUid = isValidPatchUid(existingUid); |
||||
|
|
||||
|
// UID is immutable: ignore any attempt to change/unset it via public `set`
|
||||
|
const immutableAttrs = hasExistingUid ? stripUid(rawAttrs) : rawAttrs; |
||||
|
|
||||
|
const pm = this.patchManager; |
||||
|
|
||||
|
if (!pm) { |
||||
|
return super.set(immutableAttrs as any, opts as any); |
||||
|
} |
||||
|
|
||||
|
// Never accept UID mutations via public `set` while tracking patches
|
||||
|
const attrsNoUid = stripUid(immutableAttrs); |
||||
|
|
||||
|
const beforeState = serialize(this.attributes || {}) as any; |
||||
|
const stateUid = beforeState.uid; |
||||
|
const uid = isValidPatchUid(stateUid) ? stateUid : hasExistingUid ? existingUid : pm.createId(); |
||||
|
beforeState.uid = uid; |
||||
|
|
||||
|
// Ensure UID exists before applying changes, but do not record it in patches
|
||||
|
if (!hasExistingUid && isValidPatchUid(uid)) { |
||||
|
super.set({ uid } as any, { silent: true } as any); |
||||
|
} |
||||
|
|
||||
|
if (!isValidPatchUid(uid)) { |
||||
|
return super.set(attrsNoUid as any, opts as any); |
||||
|
} |
||||
|
|
||||
|
const result = super.set(attrsNoUid as any, opts as any); |
||||
|
const afterState = serialize(this.attributes || {}); |
||||
|
(afterState as any).uid = uid; |
||||
|
const [, patches, inversePatches] = produceWithPatches<any>(beforeState, (draft: any) => { |
||||
|
syncDraftToState(draft, afterState); |
||||
|
}); |
||||
|
|
||||
|
const excludedPaths = this.getPatchExcludedPaths(); |
||||
|
const nextPatches = filterExcludedPatches(patches, excludedPaths); |
||||
|
const nextInversePatches = filterExcludedPatches(inversePatches, excludedPaths); |
||||
|
|
||||
|
if (nextPatches.length || nextInversePatches.length) { |
||||
|
const prefix: PatchPath = [this.patchObjectType as string, uid, 'attributes']; |
||||
|
const activePatch = pm.createOrGetCurrentPatch(); |
||||
|
activePatch.changes.push(...normalizePatchPaths(nextPatches, prefix)); |
||||
|
// Reverse changes should be applied in reverse order.
|
||||
|
activePatch.reverseChanges.unshift(...normalizePatchPaths(nextInversePatches, prefix)); |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,352 @@ |
|||||
|
import { createId, serialize } from '../utils/mixins'; |
||||
|
import { applyPatches } from 'immer'; |
||||
|
|
||||
|
export type PatchOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; |
||||
|
|
||||
|
export type PatchPath = Array<string | number>; |
||||
|
|
||||
|
export type PatchChangeProps = { |
||||
|
op: PatchOp; |
||||
|
path: PatchPath; |
||||
|
value?: any; |
||||
|
from?: PatchPath; |
||||
|
}; |
||||
|
|
||||
|
export type PatchProps = { |
||||
|
id: string; |
||||
|
changes: PatchChangeProps[]; |
||||
|
reverseChanges: PatchChangeProps[]; |
||||
|
}; |
||||
|
|
||||
|
export type PatchApplyOptions = { |
||||
|
external?: boolean; |
||||
|
direction?: 'forward' | 'backward'; |
||||
|
}; |
||||
|
|
||||
|
export type PatchApplyHandler = (changes: PatchChangeProps[], options?: PatchApplyOptions) => void; |
||||
|
|
||||
|
export type PatchEventEmitter = { |
||||
|
trigger: (event: string, payload?: any) => void; |
||||
|
}; |
||||
|
|
||||
|
export type PatchManagerOptions = { |
||||
|
enabled?: boolean; |
||||
|
emitter?: PatchEventEmitter; |
||||
|
applyPatch?: PatchApplyHandler; |
||||
|
}; |
||||
|
|
||||
|
export const PatchManagerEvents = { |
||||
|
update: 'patch:update', |
||||
|
undo: 'patch:undo', |
||||
|
redo: 'patch:redo', |
||||
|
} as const; |
||||
|
|
||||
|
type InternalPatch = PatchProps & { recordable: boolean }; |
||||
|
|
||||
|
const createPatchId = () => { |
||||
|
// Prefer UUID when available, fallback to legacy id generator
|
||||
|
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID; |
||||
|
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId(); |
||||
|
}; |
||||
|
|
||||
|
const isValidPatchUid = (uid: any): uid is string | number => { |
||||
|
if (typeof uid === 'string') return uid !== ''; |
||||
|
return typeof uid === 'number'; |
||||
|
}; |
||||
|
|
||||
|
export default class PatchManager { |
||||
|
isEnabled: boolean; |
||||
|
private emitter?: PatchEventEmitter; |
||||
|
private applyHandler?: PatchApplyHandler; |
||||
|
private history: PatchProps[] = []; |
||||
|
private redoStack: PatchProps[] = []; |
||||
|
private activePatch?: InternalPatch; |
||||
|
private updateDepth = 0; |
||||
|
private finalizeScheduled = false; |
||||
|
private suppressTracking = false; |
||||
|
private trackedModels: Record<string, Record<string, any>> = {}; |
||||
|
private trackedCollections: Record<string, Record<string, any>> = {}; |
||||
|
|
||||
|
constructor(options: PatchManagerOptions = {}) { |
||||
|
this.isEnabled = !!options.enabled; |
||||
|
this.emitter = options.emitter; |
||||
|
this.applyHandler = options.applyPatch; |
||||
|
} |
||||
|
|
||||
|
trackModel(model: any): void { |
||||
|
if (!model) return; |
||||
|
const type = model.patchObjectType; |
||||
|
const id = |
||||
|
typeof model.getPatchObjectId === 'function' ? model.getPatchObjectId() : (model.get?.('uid') ?? model.uid); |
||||
|
if (!type || !isValidPatchUid(id)) return; |
||||
|
const idStr = String(id); |
||||
|
this.trackedModels[type] = this.trackedModels[type] || {}; |
||||
|
this.trackedModels[type][idStr] = model; |
||||
|
} |
||||
|
|
||||
|
untrackModel(model: any): void { |
||||
|
if (!model) return; |
||||
|
const type = model.patchObjectType; |
||||
|
const id = |
||||
|
typeof model.getPatchObjectId === 'function' ? model.getPatchObjectId() : (model.get?.('uid') ?? model.uid); |
||||
|
if (!type || !isValidPatchUid(id)) return; |
||||
|
const idStr = String(id); |
||||
|
this.trackedModels[type] && delete this.trackedModels[type][idStr]; |
||||
|
} |
||||
|
|
||||
|
trackCollection(collection: any): void { |
||||
|
if (!collection) return; |
||||
|
const type = collection.patchObjectType; |
||||
|
const id = |
||||
|
typeof collection.getPatchCollectionId === 'function' |
||||
|
? collection.getPatchCollectionId() |
||||
|
: collection.collectionId; |
||||
|
if (!type || !isValidPatchUid(id)) return; |
||||
|
const idStr = String(id); |
||||
|
this.trackedCollections[type] = this.trackedCollections[type] || {}; |
||||
|
this.trackedCollections[type][idStr] = collection; |
||||
|
} |
||||
|
|
||||
|
untrackCollection(collection: any): void { |
||||
|
if (!collection) return; |
||||
|
const type = collection.patchObjectType; |
||||
|
const id = |
||||
|
typeof collection.getPatchCollectionId === 'function' |
||||
|
? collection.getPatchCollectionId() |
||||
|
: collection.collectionId; |
||||
|
if (!type || !isValidPatchUid(id)) return; |
||||
|
const idStr = String(id); |
||||
|
this.trackedCollections[type] && delete this.trackedCollections[type][idStr]; |
||||
|
} |
||||
|
|
||||
|
createId(): string { |
||||
|
return createPatchId(); |
||||
|
} |
||||
|
|
||||
|
createOrGetCurrentPatch(): PatchProps { |
||||
|
if (!this.shouldRecord()) { |
||||
|
return this.createVoidPatch(); |
||||
|
} |
||||
|
|
||||
|
if (!this.activePatch) { |
||||
|
this.activePatch = this.createPatch(); |
||||
|
|
||||
|
if (!this.updateDepth) { |
||||
|
this.scheduleFinalize(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return this.activePatch; |
||||
|
} |
||||
|
|
||||
|
finalizeCurrentPatch(): void { |
||||
|
const patch = this.activePatch; |
||||
|
this.activePatch = undefined; |
||||
|
this.finalizeScheduled = false; |
||||
|
|
||||
|
if (!patch || !patch.recordable) return; |
||||
|
if (!patch.changes.length && !patch.reverseChanges.length) return; |
||||
|
|
||||
|
this.add(patch); |
||||
|
} |
||||
|
|
||||
|
update(cb: () => void): void { |
||||
|
if (!this.isEnabled) { |
||||
|
cb(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.updateDepth++; |
||||
|
this.createOrGetCurrentPatch(); |
||||
|
|
||||
|
try { |
||||
|
cb(); |
||||
|
} finally { |
||||
|
this.updateDepth--; |
||||
|
|
||||
|
if (this.updateDepth === 0) { |
||||
|
this.finalizeCurrentPatch(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
add(patch: PatchProps): void { |
||||
|
if (!this.shouldRecord()) return; |
||||
|
|
||||
|
this.history.push(patch); |
||||
|
this.redoStack = []; |
||||
|
this.emit(PatchManagerEvents.update, patch); |
||||
|
} |
||||
|
|
||||
|
apply(patch: PatchProps, opts: { external?: boolean } = {}): void { |
||||
|
if (!this.isEnabled) return; |
||||
|
|
||||
|
const { external = false } = opts; |
||||
|
const addToHistory = !external; |
||||
|
|
||||
|
if (addToHistory) { |
||||
|
this.finalizeCurrentPatch(); |
||||
|
} |
||||
|
|
||||
|
this.applyChanges(patch.changes, { external, direction: 'forward' }); |
||||
|
|
||||
|
if (addToHistory) { |
||||
|
this.history.push(patch); |
||||
|
this.redoStack = []; |
||||
|
this.emit(PatchManagerEvents.update, patch); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
undo(): PatchProps | undefined { |
||||
|
if (!this.isEnabled) return; |
||||
|
|
||||
|
this.finalizeCurrentPatch(); |
||||
|
const patch = this.history.pop(); |
||||
|
if (!patch) return; |
||||
|
|
||||
|
this.applyChanges(patch.reverseChanges, { direction: 'backward' }); |
||||
|
this.redoStack.push(patch); |
||||
|
this.emit(PatchManagerEvents.undo, patch); |
||||
|
|
||||
|
return patch; |
||||
|
} |
||||
|
|
||||
|
redo(): PatchProps | undefined { |
||||
|
if (!this.isEnabled) return; |
||||
|
|
||||
|
this.finalizeCurrentPatch(); |
||||
|
|
||||
|
const patch = this.redoStack.pop(); |
||||
|
if (!patch) return; |
||||
|
|
||||
|
this.applyChanges(patch.changes, { direction: 'forward' }); |
||||
|
this.history.push(patch); |
||||
|
this.emit(PatchManagerEvents.redo, patch); |
||||
|
|
||||
|
return patch; |
||||
|
} |
||||
|
|
||||
|
private applyChanges(changes: PatchChangeProps[], options: PatchApplyOptions = {}) { |
||||
|
if (!changes.length) return; |
||||
|
|
||||
|
this.withSuppressedTracking(() => { |
||||
|
if (this.applyHandler) { |
||||
|
this.applyHandler(changes, options); |
||||
|
} else { |
||||
|
this.applyTrackedChanges(changes); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private applyTrackedChanges(changes: PatchChangeProps[]) { |
||||
|
const modelGroups = new Map<string, { type: string; id: string; patches: PatchChangeProps[] }>(); |
||||
|
|
||||
|
changes.forEach((change) => { |
||||
|
const path = change.path || []; |
||||
|
if (path.length < 3) return; |
||||
|
const type = String(path[0]); |
||||
|
const targetId = String(path[1]); |
||||
|
const scope = String(path[2]); |
||||
|
|
||||
|
if (scope === 'attributes') { |
||||
|
const groupKey = `${type}::${targetId}`; |
||||
|
const group = modelGroups.get(groupKey) || { type, id: targetId, patches: [] }; |
||||
|
group.patches.push(change); |
||||
|
modelGroups.set(groupKey, group); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (scope === 'order') { |
||||
|
const modelId = path[3] != null ? String(path[3]) : ''; |
||||
|
const coll = this.trackedCollections[type]?.[targetId]; |
||||
|
if (coll && typeof coll.applyOrderKeyPatch === 'function') { |
||||
|
coll.applyOrderKeyPatch(modelId, change.op, change.value); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
modelGroups.forEach(({ type, id, patches }) => { |
||||
|
const model = this.trackedModels[type]?.[id]; |
||||
|
if (!model || typeof model.set !== 'function') return; |
||||
|
|
||||
|
const current = serialize(model.attributes || {}); |
||||
|
const localPatches = patches.map((p) => ({ |
||||
|
...p, |
||||
|
path: (p.path || []).slice(3), |
||||
|
...(p.from ? { from: (p.from || []).slice(3) } : {}), |
||||
|
})) as any; |
||||
|
|
||||
|
const next = applyPatches(current, localPatches); |
||||
|
const toSet: any = {}; |
||||
|
const toUnset: string[] = []; |
||||
|
|
||||
|
Object.keys(next).forEach((key) => { |
||||
|
if (current[key] !== next[key]) { |
||||
|
toSet[key] = next[key]; |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
Object.keys(current).forEach((key) => { |
||||
|
if (!(key in next)) { |
||||
|
toUnset.push(key); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
Object.keys(toSet).length && model.set(toSet); |
||||
|
toUnset.forEach((key) => model.unset?.(key)); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
withSuppressedTracking<T>(cb: () => T): T { |
||||
|
const prevSuppress = this.suppressTracking; |
||||
|
this.suppressTracking = true; |
||||
|
|
||||
|
try { |
||||
|
return cb(); |
||||
|
} finally { |
||||
|
this.suppressTracking = prevSuppress; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private shouldRecord() { |
||||
|
return this.isEnabled && !this.suppressTracking; |
||||
|
} |
||||
|
|
||||
|
private createPatch(): InternalPatch { |
||||
|
return { |
||||
|
id: createPatchId(), |
||||
|
changes: [], |
||||
|
reverseChanges: [], |
||||
|
recordable: true, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private createVoidPatch(): InternalPatch { |
||||
|
return { |
||||
|
id: '', |
||||
|
changes: [], |
||||
|
reverseChanges: [], |
||||
|
recordable: false, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
private scheduleFinalize() { |
||||
|
if (this.updateDepth || this.finalizeScheduled) return; |
||||
|
this.finalizeScheduled = true; |
||||
|
|
||||
|
Promise.resolve().then(() => { |
||||
|
this.finalizeScheduled = false; |
||||
|
|
||||
|
if (!this.updateDepth) { |
||||
|
this.finalizeCurrentPatch(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private emit(event: string, payload: PatchProps): void { |
||||
|
this.emitter?.trigger?.(event, payload); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export { default as CollectionWithPatches } from './CollectionWithPatches'; |
||||
|
export { PatchObjectsRegistry, createRegistryApplyPatchHandler, type PatchUid } from './registry'; |
||||
@ -0,0 +1,90 @@ |
|||||
|
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,222 @@ |
|||||
|
// Based on rocicorp/fractional-indexing (CC0)
|
||||
|
// 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,204 @@ |
|||||
|
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); |
||||
|
}); |
||||
|
}); |
||||
@ -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,92 @@ |
|||||
|
import PatchManager, { PatchManagerEvents } from 'patch_manager'; |
||||
|
|
||||
|
describe('PatchManager', () => { |
||||
|
test('Records a patch during update and emits update event', () => { |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
emitter: { |
||||
|
trigger: (event, payload) => events.push({ event, payload }), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
pm.update(() => { |
||||
|
const patch = pm.createOrGetCurrentPatch(); |
||||
|
patch.changes.push({ op: 'replace', path: ['value'], value: 1 }); |
||||
|
patch.reverseChanges.push({ op: 'replace', path: ['value'], value: 0 }); |
||||
|
}); |
||||
|
|
||||
|
expect(events).toHaveLength(1); |
||||
|
expect(events[0].event).toBe(PatchManagerEvents.update); |
||||
|
expect(events[0].payload.changes).toHaveLength(1); |
||||
|
expect(events[0].payload.reverseChanges).toHaveLength(1); |
||||
|
}); |
||||
|
|
||||
|
test('Applies patches and respects the external flag', () => { |
||||
|
const calls = []; |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
applyPatch: (changes, options) => calls.push({ changes, options }), |
||||
|
emitter: { |
||||
|
trigger: (event) => events.push(event), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const patch = { |
||||
|
id: 'patch-1', |
||||
|
changes: [{ op: 'add', path: ['value'], value: 1 }], |
||||
|
reverseChanges: [{ op: 'remove', path: ['value'] }], |
||||
|
}; |
||||
|
|
||||
|
pm.apply(patch); |
||||
|
|
||||
|
expect(calls).toHaveLength(1); |
||||
|
expect(calls[0]).toEqual({ |
||||
|
changes: patch.changes, |
||||
|
options: { external: false, direction: 'forward' }, |
||||
|
}); |
||||
|
expect(events).toEqual([PatchManagerEvents.update]); |
||||
|
|
||||
|
calls.length = 0; |
||||
|
events.length = 0; |
||||
|
|
||||
|
pm.apply(patch, { external: true }); |
||||
|
|
||||
|
expect(calls).toHaveLength(1); |
||||
|
expect(calls[0]).toEqual({ |
||||
|
changes: patch.changes, |
||||
|
options: { external: true, direction: 'forward' }, |
||||
|
}); |
||||
|
expect(events).toHaveLength(0); |
||||
|
}); |
||||
|
|
||||
|
test('Undo and redo apply reverse/forward changes', () => { |
||||
|
const calls = []; |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
applyPatch: (changes, options) => calls.push({ changes, options }), |
||||
|
emitter: { |
||||
|
trigger: (event) => events.push(event), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const patch = { |
||||
|
id: 'patch-2', |
||||
|
changes: [{ op: 'replace', path: ['value'], value: 2 }], |
||||
|
reverseChanges: [{ op: 'replace', path: ['value'], value: 1 }], |
||||
|
}; |
||||
|
|
||||
|
pm.add(patch); |
||||
|
|
||||
|
const undoPatch = pm.undo(); |
||||
|
const redoPatch = pm.redo(); |
||||
|
|
||||
|
expect(undoPatch).toBe(patch); |
||||
|
expect(redoPatch).toBe(patch); |
||||
|
expect(calls[0]).toEqual({ changes: patch.reverseChanges, options: { direction: 'backward' } }); |
||||
|
expect(calls[1]).toEqual({ changes: patch.changes, options: { direction: 'forward' } }); |
||||
|
expect(events).toEqual([PatchManagerEvents.update, PatchManagerEvents.undo, PatchManagerEvents.redo]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,146 @@ |
|||||
|
import PatchManager, { PatchManagerEvents } from 'patch_manager'; |
||||
|
import ModelWithPatches from 'patch_manager/ModelWithPatches'; |
||||
|
|
||||
|
describe('ModelWithPatches', () => { |
||||
|
test('set records patch with normalized path', async () => { |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
emitter: { |
||||
|
trigger: (event, payload) => events.push({ event, payload }), |
||||
|
}, |
||||
|
}); |
||||
|
pm.createId = () => 'uid-1'; |
||||
|
|
||||
|
const model = new ModelWithPatches({ id: 'model-1', foo: 'bar' }); |
||||
|
model.em = { Patches: pm }; |
||||
|
model.patchObjectType = 'model'; |
||||
|
|
||||
|
model.set('foo', 'baz'); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(events).toHaveLength(1); |
||||
|
expect(events[0].event).toBe(PatchManagerEvents.update); |
||||
|
|
||||
|
const patch = events[0].payload; |
||||
|
expect(model.get('uid')).toBe('uid-1'); |
||||
|
expect(patch.changes).toHaveLength(1); |
||||
|
expect(patch.reverseChanges).toHaveLength(1); |
||||
|
expect(patch.changes[0]).toMatchObject({ |
||||
|
op: 'replace', |
||||
|
path: ['model', 'uid-1', 'attributes', 'foo'], |
||||
|
value: 'baz', |
||||
|
}); |
||||
|
expect(patch.reverseChanges[0]).toMatchObject({ |
||||
|
op: 'replace', |
||||
|
path: ['model', 'uid-1', 'attributes', 'foo'], |
||||
|
value: 'bar', |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('set skips patch recording without a patch object type', async () => { |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
emitter: { |
||||
|
trigger: (event, payload) => events.push({ event, payload }), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const model = new ModelWithPatches({ id: 'model-2', foo: 'bar' }); |
||||
|
model.em = { Patches: pm }; |
||||
|
|
||||
|
model.set('foo', 'baz'); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(model.get('foo')).toBe('baz'); |
||||
|
expect(events).toHaveLength(0); |
||||
|
}); |
||||
|
|
||||
|
test('apply handler changes do not create patches while tracking is suppressed', async () => { |
||||
|
const events = []; |
||||
|
let model; |
||||
|
|
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
emitter: { |
||||
|
trigger: (event, payload) => events.push({ event, payload }), |
||||
|
}, |
||||
|
applyPatch: () => { |
||||
|
model.set('foo', 'applied'); |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
model = new ModelWithPatches({ uid: 'uid-3', id: 'model-3', foo: 'bar' }); |
||||
|
model.em = { Patches: pm }; |
||||
|
model.patchObjectType = 'model'; |
||||
|
|
||||
|
pm.apply( |
||||
|
{ |
||||
|
id: 'patch-3', |
||||
|
changes: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'applied' }], |
||||
|
reverseChanges: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'bar' }], |
||||
|
}, |
||||
|
{ external: true }, |
||||
|
); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(model.get('foo')).toBe('applied'); |
||||
|
expect(events).toHaveLength(0); |
||||
|
}); |
||||
|
|
||||
|
test('apply(external) updates tracked model without custom applyPatch', async () => { |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
emitter: { |
||||
|
trigger: (event, payload) => events.push({ event, payload }), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const model = new ModelWithPatches({ uid: 'uid-4', foo: 'bar' }); |
||||
|
model.em = { Patches: pm }; |
||||
|
model.patchObjectType = 'model'; |
||||
|
|
||||
|
pm.trackModel(model); |
||||
|
|
||||
|
pm.apply( |
||||
|
{ |
||||
|
id: 'patch-4', |
||||
|
changes: [{ op: 'replace', path: ['model', 'uid-4', 'attributes', 'foo'], value: 'baz' }], |
||||
|
reverseChanges: [{ op: 'replace', path: ['model', 'uid-4', 'attributes', 'foo'], value: 'bar' }], |
||||
|
}, |
||||
|
{ external: true }, |
||||
|
); |
||||
|
|
||||
|
expect(model.get('foo')).toBe('baz'); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(events).toHaveLength(0); |
||||
|
}); |
||||
|
|
||||
|
test('uid is immutable once set', async () => { |
||||
|
const events = []; |
||||
|
const pm = new PatchManager({ |
||||
|
enabled: true, |
||||
|
emitter: { |
||||
|
trigger: (event, payload) => events.push({ event, payload }), |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
const model = new ModelWithPatches({ uid: 'uid-4', foo: 'bar' }); |
||||
|
model.em = { Patches: pm }; |
||||
|
model.patchObjectType = 'model'; |
||||
|
|
||||
|
model.set('uid', 'uid-changed'); |
||||
|
|
||||
|
await Promise.resolve(); |
||||
|
|
||||
|
expect(model.get('uid')).toBe('uid-4'); |
||||
|
expect(events).toHaveLength(0); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,32 @@ |
|||||
|
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