mirror of https://github.com/artf/grapesjs.git
nocodeframeworkdrag-and-dropsite-buildersite-generatortemplate-builderui-builderweb-builderweb-builder-frameworkwebsite-builderno-codepage-builder
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
308 lines
9.6 KiB
308 lines
9.6 KiB
import { generateNKeysBetween } from '../utils/fractionalIndex';
|
|
import { Collection, Model, AddOptions } from '../common';
|
|
import EditorModel from '../editor/model/Editor';
|
|
import PatchManager, { PatchChangeProps, PatchPath } from './index';
|
|
|
|
export interface CollectionWithPatchesOptions extends AddOptions {
|
|
em?: EditorModel;
|
|
collectionId?: string;
|
|
patchObjectType?: string;
|
|
}
|
|
|
|
export type FractionalEntry<T extends Model = Model> = {
|
|
id: string;
|
|
key: string;
|
|
model?: T | undefined;
|
|
};
|
|
|
|
type PendingRemoval = {
|
|
oldKey: string;
|
|
patch: any;
|
|
change: PatchChangeProps;
|
|
reverse: PatchChangeProps;
|
|
};
|
|
|
|
export default class CollectionWithPatches<T extends Model = Model> extends Collection<T> {
|
|
em?: EditorModel;
|
|
collectionId?: string;
|
|
patchObjectType?: string;
|
|
private fractionalMap: Record<string, string> = {};
|
|
private pendingRemovals: Record<string, PendingRemoval> = {};
|
|
private suppressSortRebuild = false;
|
|
private isResetting = false;
|
|
|
|
constructor(models?: any, options: CollectionWithPatchesOptions = {}) {
|
|
const nextOptions = { ...options };
|
|
super(models, nextOptions);
|
|
this.em = nextOptions.em;
|
|
this.collectionId = nextOptions.collectionId;
|
|
this.patchObjectType = nextOptions.patchObjectType;
|
|
this.on('sort', this.handleSort, this);
|
|
this.rebuildFractionalMap(false);
|
|
|
|
// Ensure tracking/registry works for apply(external) in enabled mode.
|
|
Promise.resolve().then(() => {
|
|
const pm = this.patchManager;
|
|
if (pm?.isEnabled) {
|
|
pm.trackCollection?.(this as any);
|
|
}
|
|
});
|
|
}
|
|
|
|
get patchManager(): PatchManager | undefined {
|
|
return this.em?.Patches;
|
|
}
|
|
|
|
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.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);
|
|
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);
|
|
this.fractionalMap = {};
|
|
this.pendingRemovals = {};
|
|
this.rebuildFractionalMap();
|
|
return result;
|
|
} finally {
|
|
this.isResetting = false;
|
|
}
|
|
}
|
|
|
|
protected handleSort(_collection?: any, options: any = {}) {
|
|
if (this.suppressSortRebuild || options?.fromPatches) return;
|
|
this.rebuildFractionalMap();
|
|
}
|
|
|
|
protected getPatchCollectionId(): string | undefined {
|
|
return this.collectionId || (this as any).cid;
|
|
}
|
|
|
|
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) {
|
|
const ids = this.models.map((model) => this.getModelId(model)).filter(Boolean);
|
|
const keys = ids.length ? generateNKeysBetween(null, null, ids.length) : [];
|
|
const prevMap = { ...this.fractionalMap };
|
|
const nextMap: Record<string, string> = {};
|
|
|
|
ids.forEach((id, index) => {
|
|
const key = keys[index];
|
|
nextMap[id] = key;
|
|
if (record) {
|
|
this.recordFractionalPatch(id, key, prevMap[id]);
|
|
}
|
|
});
|
|
|
|
if (record) {
|
|
Object.keys(prevMap).forEach((id) => {
|
|
if (!(id in nextMap)) {
|
|
this.recordFractionalPatch(id, undefined, prevMap[id]);
|
|
}
|
|
});
|
|
}
|
|
|
|
this.fractionalMap = nextMap;
|
|
}
|
|
|
|
protected assignKeysForMissingModels() {
|
|
let idx = 0;
|
|
const models = this.models;
|
|
|
|
while (idx < models.length) {
|
|
const model = models[idx];
|
|
const id = this.getModelId(model);
|
|
|
|
if (!id || this.fractionalMap[id]) {
|
|
idx++;
|
|
continue;
|
|
}
|
|
|
|
const segmentIds: string[] = [];
|
|
const segmentStartIdx = idx;
|
|
|
|
while (idx < models.length) {
|
|
const segId = this.getModelId(models[idx]);
|
|
if (!segId || this.fractionalMap[segId]) break;
|
|
segmentIds.push(segId);
|
|
idx++;
|
|
}
|
|
|
|
// Find previous and next keys around the segment, based on current collection order.
|
|
let prevKey: string | null = null;
|
|
for (let i = segmentStartIdx - 1; i >= 0; i--) {
|
|
const prevId = this.getModelId(models[i]);
|
|
if (prevId && this.fractionalMap[prevId]) {
|
|
prevKey = this.fractionalMap[prevId];
|
|
break;
|
|
}
|
|
}
|
|
|
|
let nextKey: string | null = null;
|
|
for (let i = idx; i < models.length; i++) {
|
|
const nextId = this.getModelId(models[i]);
|
|
if (nextId && this.fractionalMap[nextId]) {
|
|
nextKey = this.fractionalMap[nextId];
|
|
break;
|
|
}
|
|
}
|
|
|
|
const keys = generateNKeysBetween(prevKey, nextKey, segmentIds.length);
|
|
segmentIds.forEach((segId, i) => {
|
|
const newKey = keys[i];
|
|
this.fractionalMap[segId] = newKey;
|
|
|
|
const pending = this.pendingRemovals[segId];
|
|
if (pending) {
|
|
this.removeRecordedPatch(pending);
|
|
delete this.pendingRemovals[segId];
|
|
this.recordFractionalPatch(segId, newKey, pending.oldKey);
|
|
} else {
|
|
this.recordFractionalPatch(segId, newKey, undefined);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
protected getModelId(model: T): string {
|
|
if (!model) return '';
|
|
if (typeof (model as any).getId === 'function') {
|
|
const id = (model as any).getId();
|
|
const valid = typeof id === 'string' ? id !== '' : typeof id === 'number';
|
|
return valid ? String(id) : '';
|
|
}
|
|
const id = (model as any).get?.('id');
|
|
return (id as string) || model.cid || '';
|
|
}
|
|
|
|
protected recordFractionalPatch(id: string, newKey?: string, oldKey?: string): PendingRemoval | void {
|
|
const pm = this.patchManager;
|
|
const objectType = this.patchObjectType;
|
|
const collectionId = this.getPatchCollectionId();
|
|
if (!pm || !pm.isEnabled || !objectType || !collectionId) return;
|
|
if (newKey === oldKey) return;
|
|
|
|
const path: PatchPath = [objectType, collectionId, 'order', id];
|
|
let change: PatchChangeProps;
|
|
let reverse: PatchChangeProps;
|
|
|
|
if (newKey === undefined) {
|
|
change = { op: 'remove', path };
|
|
reverse = { op: 'add', path, value: oldKey };
|
|
} else if (oldKey === undefined) {
|
|
change = { op: 'add', path, value: newKey };
|
|
reverse = { op: 'remove', path };
|
|
} else {
|
|
change = { op: 'replace', path, value: newKey };
|
|
reverse = { op: 'replace', path, value: oldKey };
|
|
}
|
|
|
|
const patch = pm.createOrGetCurrentPatch();
|
|
patch.changes.push(change);
|
|
// Reverse changes should be applied in reverse order.
|
|
patch.reverseChanges.unshift(reverse);
|
|
|
|
if (newKey === undefined && oldKey != null) {
|
|
return { oldKey, patch, change, reverse };
|
|
}
|
|
}
|
|
|
|
getAndSortFractionalMap(): FractionalEntry<T>[] {
|
|
return Object.entries(this.fractionalMap)
|
|
.sort(([idA, keyA], [idB, keyB]) => keyA.localeCompare(keyB) || idA.localeCompare(idB))
|
|
.map(([id, key]) => ({ id, key, model: this.getModelByPatchId(id) }));
|
|
}
|
|
|
|
getOrderKey(id: string) {
|
|
return this.fractionalMap[id];
|
|
}
|
|
|
|
applyOrderKeyPatch(id: string, op: PatchChangeProps['op'], value?: string) {
|
|
if (!id) return;
|
|
|
|
if (op === 'remove') {
|
|
delete this.fractionalMap[id];
|
|
const model = this.getModelByPatchId(id);
|
|
model && Collection.prototype.remove.call(this, model 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);
|
|
}
|
|
}
|
|
|