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 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; |
|||
|
|||
@ -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