Browse Source

update logic

pull/6680/head
IhorKaleniuk666 4 months ago
parent
commit
f8c1964def
  1. 154
      packages/core/src/dom_components/model/Component.ts
  2. 186
      packages/core/src/dom_components/model/Components.ts
  3. 22
      packages/core/src/domain_abstract/model/StyleableModel.ts
  4. 39
      packages/core/src/patch_manager/CollectionWithPatches.ts
  5. 74
      packages/core/src/patch_manager/ModelWithPatches.ts
  6. 2
      packages/core/src/patch_manager/index.ts
  7. 222
      packages/core/src/utils/fractionalIndex.ts
  8. 252
      packages/core/test/specs/patch_manager/components.js

154
packages/core/src/dom_components/model/Component.ts

@ -1,4 +1,5 @@
import { Model, ModelDestroyOptions } from 'backbone';
import PatchManager, { type PatchPath } from '../../patch_manager';
import {
bindAll,
forEach,
@ -158,6 +159,22 @@ type GetComponentStyleOpts = GetStyleOpts & {
* @module docsjs.Component
*/
export default class Component extends StyleableModel<ComponentProperties> {
patchObjectType = 'components';
protected getPatchExcludedPaths(): PatchPath[] {
return [
['toolbar'],
['traits'],
['status'],
['open'],
['delegate'],
['_undoexc'],
['dataResolverWatchers'],
['attributes', 'class'],
// Structural changes are tracked via `componentsOrder`
['components'],
];
}
/**
* @private
* @ts-ignore */
@ -292,75 +309,84 @@ export default class Component extends StyleableModel<ComponentProperties> {
bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps', 'syncOnComponentChange');
// Propagate properties from parent if indicated
const parent = this.parent();
const parentAttr = parent?.attributes;
const propagate = this.get('propagate');
propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]);
if (parentAttr && parentAttr.propagate && !propagate) {
const newAttr: Partial<ComponentProperties> = {};
const toPropagate = parentAttr.propagate;
toPropagate.forEach((prop) => (newAttr[prop] = parent.get(prop as string)));
newAttr.propagate = toPropagate;
this.set({ ...newAttr, ...props });
}
const pm = em && ((em as any).Patches as PatchManager | undefined);
const init = () => {
// Propagate properties from parent if indicated
const parent = this.parent();
const parentAttr = parent?.attributes;
const propagate = this.get('propagate');
propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]);
if (parentAttr && parentAttr.propagate && !propagate) {
const newAttr: Partial<ComponentProperties> = {};
const toPropagate = parentAttr.propagate;
toPropagate.forEach((prop) => (newAttr[prop] = parent.get(prop as string)));
newAttr.propagate = toPropagate;
this.set({ ...newAttr, ...props });
}
// Check void elements
if (opt && opt.config && opt.config.voidElements!.indexOf(this.get('tagName')!) >= 0) {
this.set('void', true);
}
// Check void elements
if (opt && opt.config && opt.config.voidElements!.indexOf(this.get('tagName')!) >= 0) {
this.set('void', true);
}
opt.em = em;
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt as any);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
this.initToolbar();
this.initScriptProps();
this.listenTo(this, 'change:script', this.scriptUpdated);
this.listenTo(this, 'change:tagName', this.tagUpdated);
this.listenTo(this, 'change:attributes', this.attrUpdated);
this.listenTo(this, 'change:attributes:id', this._idUpdated);
this.on('change:toolbar', this.__emitUpdateTlb);
this.on('change', this.__onChange);
this.on(keyUpdateInside, this.__propToParent);
this.set('status', '');
this.views = [];
// Register global updates for collection properties
['classes', 'traits', 'components'].forEach((name) => {
const events = `add remove reset ${name !== 'components' ? 'change' : ''}`;
this.listenTo(this.get(name), events.trim(), (...args) => this.emitUpdate(name, ...args));
});
opt.em = em;
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt as any);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
this.initToolbar();
this.initScriptProps();
this.listenTo(this, 'change:script', this.scriptUpdated);
this.listenTo(this, 'change:tagName', this.tagUpdated);
this.listenTo(this, 'change:attributes', this.attrUpdated);
this.listenTo(this, 'change:attributes:id', this._idUpdated);
this.on('change:toolbar', this.__emitUpdateTlb);
this.on('change', this.__onChange);
this.on(keyUpdateInside, this.__propToParent);
this.set('status', '');
this.views = [];
// Register global updates for collection properties
['classes', 'traits', 'components'].forEach((name) => {
const events = `add remove reset ${name !== 'components' ? 'change' : ''}`;
this.listenTo(this.get(name), events.trim(), (...args) => this.emitUpdate(name, ...args));
});
if (!opt.temporary) {
// Add component styles
const cssc = em && em.Css;
const { styles, type } = this.attributes;
if (styles && cssc) {
cssc.addCollection(styles, { avoidUpdateStyle: true }, { group: `cmp:${type}` });
if (!opt.temporary) {
// Add component styles
const cssc = em && em.Css;
const { styles, type } = this.attributes;
if (styles && cssc) {
cssc.addCollection(styles, { avoidUpdateStyle: true }, { group: `cmp:${type}` });
}
this._moveInlineStyleToRule();
this.__postAdd();
this.init();
isSymbol(this) && initSymbol(this);
em?.trigger(ComponentsEvents.create, this, opt);
}
this._moveInlineStyleToRule();
this.__postAdd();
this.init();
isSymbol(this) && initSymbol(this);
em?.trigger(ComponentsEvents.create, this, opt);
}
if (avoidInline(em)) {
this.dataResolverWatchers.disableStyles();
}
};
if (avoidInline(em)) {
this.dataResolverWatchers.disableStyles();
if (pm?.isEnabled) {
pm.withSuppressedTracking(init);
} else {
init();
}
}
@ -1017,7 +1043,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
// Have to add components after the init, otherwise the parent
// is not visible
const comps = new Components([], this.opt);
comps.parent = this;
comps.setParent(this);
const components = this.get('components');
const addChild = !this.opt.avoidChildren;
this.set('components', comps);

186
packages/core/src/dom_components/model/Components.ts

@ -1,10 +1,13 @@
import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore';
import Component, { SetAttrOptions } from './Component';
import { AddOptions, Collection } from '../../common';
import { AddOptions } from '../../common';
import { DomComponentsConfig } from '../config/config';
import EditorModel from '../../editor/model/Editor';
import ComponentManager from '..';
import CssRule from '../../css_composer/model/CssRule';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import { generateKeyBetween, generateNKeysBetween } from '../../utils/fractionalIndex';
import { serialize } from '../../utils/mixins';
import {
ComponentAdd,
@ -121,7 +124,23 @@ interface AddComponentOptions extends AddOptions {
keepIds?: string[];
}
export default class Components extends Collection</**
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const isOrderMap = (value: any): value is Record<string, any> =>
value != null && typeof value === 'object' && !Array.isArray(value);
const getOrderKeyByUid = (orderMap: Record<string, any>, uid: string | number) => {
const entries = Object.entries(orderMap);
const match = entries.find(([, value]) => value === uid);
return match ? match[0] : undefined;
};
const TEMP_MOVE_FLAG = '__patchTempMove';
export default class Components extends CollectionWithPatches</**
* Keep this format to avoid errors in TS bundler */
/** @ts-ignore */
Component> {
@ -132,11 +151,13 @@ Component> {
parent?: Component;
constructor(models: any, opt: ComponentsOptions) {
super(models, opt);
super(models, { ...opt, em: opt.em, patchObjectType: 'components' });
this.opt = opt;
this.listenTo(this, 'add', this.onAdd);
this.listenTo(this, 'remove', this.handlePatchRemove);
this.listenTo(this, 'remove', this.removeChildren);
this.listenTo(this, 'reset', this.resetChildren);
this.listenTo(this, 'add', this.handlePatchAdd);
const { em, config } = opt;
this.config = config;
this.em = em;
@ -147,6 +168,165 @@ Component> {
return this.domc?.events!;
}
setParent(parent: Component) {
this.parent = parent;
this.stopListening(parent, 'change:componentsOrder', this.handleComponentsOrderChange);
this.listenTo(parent, 'change:componentsOrder', this.handleComponentsOrderChange);
this.patchManager && this.ensureParentOrderMap();
}
protected handleComponentsOrderChange(_model: Component, value: any, opts: any = {}) {
if (opts.fromComponents) return;
if (!isOrderMap(value)) return;
const ordered = Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, uid]) => uid);
const byUid = new Map<string | number, Component>();
this.models.forEach((model) => {
const uid = (model as any).get?.('uid');
if (isValidPatchUid(uid)) {
byUid.set(uid, model);
}
});
const nextModels: Component[] = [];
ordered.forEach((uid) => {
const model = byUid.get(uid);
model && nextModels.push(model);
});
// Append leftovers (eg. models without uid/order entry) keeping current order.
const included = new Set(nextModels.map((m) => m.cid));
this.models.forEach((model) => {
if (!included.has(model.cid)) {
nextModels.push(model);
}
});
if (!nextModels.length) return;
this.models.splice(0, this.models.length, ...nextModels);
this.trigger('sort', this, { fromPatches: true });
}
protected ensureModelUid(model: Component): string | number | undefined {
const uid = (model as any).get?.('uid');
if (isValidPatchUid(uid)) return uid;
const pm = this.patchManager;
if (!pm) return;
(model as any).set?.({}, { silent: true });
const nextUid = (model as any).get?.('uid');
return isValidPatchUid(nextUid) ? nextUid : undefined;
}
protected ensureParentOrderMap(excludeUid?: string | number): Record<string, any> {
const parent = this.parent;
if (!parent) return {};
const current = parent.get('componentsOrder');
if (isOrderMap(current)) return current;
if (!this.patchManager) return {};
const models = this.models.filter((model) => {
const uid = this.ensureModelUid(model);
return uid !== excludeUid;
});
const uids = models.map((model) => this.ensureModelUid(model)).filter(isValidPatchUid);
const keys = generateNKeysBetween(null, null, uids.length);
const map: Record<string, any> = {};
uids.forEach((uid, index) => {
map[keys[index]] = uid;
});
// Initialize without recording a patch (it's a derived structure).
(parent as any).attributes.componentsOrder = map;
return map;
}
protected handlePatchAdd(model: Component, _collection: any, opts: any = {}) {
const pm = this.patchManager;
const parent = this.parent;
if (!pm || !parent) return;
const uid = this.ensureModelUid(model);
const parentUid = this.ensureModelUid(parent as any);
if (!isValidPatchUid(uid) || !isValidPatchUid(parentUid)) return;
const isTempMove = !!(model as any)[TEMP_MOVE_FLAG];
if (isTempMove) {
delete (model as any)[TEMP_MOVE_FLAG];
}
if (!isTempMove && !opts.fromUndo) {
const patch = pm.createOrGetCurrentPatch();
const attrPrefix = [this.patchObjectType as string, uid, 'attributes'];
const isAttrPatch = (p: any) => {
const { path, from } = p || {};
const startsWith = (value?: any[]) => attrPrefix.every((seg, index) => value?.[index] === seg);
return startsWith(path) || startsWith(from);
};
patch.changes = patch.changes.filter((p: any) => !isAttrPatch(p));
patch.reverseChanges = patch.reverseChanges.filter((p: any) => !isAttrPatch(p));
patch.changes.push({
op: 'add',
path: [this.patchObjectType as string, uid],
value: { attributes: serialize(model.toJSON()) },
});
patch.reverseChanges.unshift({ op: 'remove', path: [this.patchObjectType as string, uid] });
}
const baseMap = this.ensureParentOrderMap(uid);
const cleanMap = Object.fromEntries(Object.entries(baseMap).filter(([, value]) => value !== uid));
const index = this.indexOf(model);
const prevModel = index > 0 ? this.at(index - 1) : undefined;
const nextModel = index < this.length - 1 ? this.at(index + 1) : undefined;
const prevUid = prevModel ? this.ensureModelUid(prevModel) : undefined;
const nextUid = nextModel ? this.ensureModelUid(nextModel) : undefined;
const prevKey = prevUid != null ? getOrderKeyByUid(cleanMap, prevUid) : undefined;
const nextKey = nextUid != null ? getOrderKeyByUid(cleanMap, nextUid) : undefined;
const newKey = generateKeyBetween(prevKey ?? null, nextKey ?? null);
const nextMap = { ...cleanMap, [newKey]: uid };
parent.set('componentsOrder', nextMap, { ...opts, fromComponents: true });
}
protected handlePatchRemove(model: Component, _collection: any, opts: any = {}) {
const pm = this.patchManager;
const parent = this.parent;
if (!pm || !parent) return;
const uid = this.ensureModelUid(model);
const parentUid = this.ensureModelUid(parent as any);
if (!isValidPatchUid(uid) || !isValidPatchUid(parentUid)) return;
if (opts.temporary) {
(model as any)[TEMP_MOVE_FLAG] = true;
}
const currentMap = this.ensureParentOrderMap();
const orderKey = isOrderMap(currentMap) ? getOrderKeyByUid(currentMap, uid) : undefined;
if (orderKey) {
const { [orderKey]: _removed, ...rest } = currentMap;
parent.set('componentsOrder', rest, { ...opts, fromComponents: true });
}
const isTemp = opts.temporary || opts.fromUndo;
if (isTemp) return;
const patch = pm.createOrGetCurrentPatch();
patch.changes.push({ op: 'remove', path: [this.patchObjectType as string, uid] });
patch.reverseChanges.unshift({
op: 'add',
path: [this.patchObjectType as string, uid],
value: { attributes: serialize(model.toJSON()) },
});
}
resetChildren(models: Components, opts: { previousModels?: Component[]; keepIds?: string[] } = {}) {
const coll = this;
const prev = opts.previousModels || [];

22
packages/core/src/domain_abstract/model/StyleableModel.ts

@ -1,8 +1,8 @@
import { isArray, isObject, isString, keys } from 'underscore';
import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common';
import { ObjectAny, ObjectHash, SetOptions } from '../../common';
import ParserHtml from '../../parser/model/ParserHtml';
import Selectors from '../../selector_manager/model/Selectors';
import { shallowDiff } from '../../utils/mixins';
import { createId, shallowDiff } from '../../utils/mixins';
import EditorModel from '../../editor/model/Editor';
import CssRuleView from '../../css_composer/view/CssRuleView';
import ComponentView from '../../dom_components/view/ComponentView';
@ -13,6 +13,7 @@ import { DataCollectionStateMap } from '../../data_sources/model/data_collection
import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWatcher';
import { DataResolverProps } from '../../data_sources/types';
import { _StringKey } from 'backbone';
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
export type StyleProps = Record<string, string | string[] | DataResolverProps>;
@ -44,7 +45,17 @@ type WithDataResolvers<T> = {
[P in keyof T]?: T[P] | DataResolverProps;
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends Model<T, UpdateStyleOptions> {
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();
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends ModelWithPatches<T, UpdateStyleOptions> {
em?: EditorModel;
views: StyleableView[] = [];
dataResolverWatchers: ModelDataResolverWatchers<T>;
@ -312,6 +323,11 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
const mergedProps = { ...props, ...attributes };
const mergedOpts = { ...this.opt, ...opts };
const uid = (mergedProps as any).uid;
if (isValidPatchUid(uid)) {
(mergedProps as any).uid = createStableUid();
}
const ClassConstructor = this.constructor as new (attributes: any, opts?: any) => typeof this;
return new ClassConstructor(mergedProps, mergedOpts);

39
packages/core/src/patch_manager/CollectionWithPatches.ts

@ -0,0 +1,39 @@
import EditorModel from '../editor/model/Editor';
import { AddOptions, Collection, Model, RemoveOptions } from '../common';
import PatchManager from './index';
export interface CollectionWithPatchesOptions extends AddOptions {
em?: EditorModel;
patchObjectType?: string;
}
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
export default class CollectionWithPatches<T extends Model = Model> extends Collection<T> {
em?: EditorModel;
patchObjectType?: string;
constructor(models?: any, options: CollectionWithPatchesOptions = {}) {
super(models, options);
this.em = options.em;
this.patchObjectType = options.patchObjectType;
}
protected get patchManager(): PatchManager | undefined {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
return pm?.isEnabled && this.patchObjectType ? pm : undefined;
}
protected getModelUid(model: T): string | number | undefined {
const uid = (model as any)?.get?.('uid');
return isValidPatchUid(uid) ? uid : undefined;
}
protected shouldHandleRemoval(_model: T, opts?: RemoveOptions): boolean {
return !(opts as any)?.temporary;
}
}

74
packages/core/src/patch_manager/ModelWithPatches.ts

@ -35,6 +35,39 @@ const normalizePatchPaths = (patches: PatchChangeProps[], prefix: PatchPath): Pa
}));
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];
@ -42,7 +75,16 @@ const syncDraftToState = (draft: any, target: any) => {
});
Object.keys(target).forEach((key) => {
draft[key] = target[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;
}
});
};
@ -66,10 +108,29 @@ const stripUid = <T extends ObjectHash>(attrs: Partial<T>): 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;
protected getPatchExcludedPaths(): PatchPath[] {
return [];
}
protected get patchManager(): PatchManager | undefined {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
return pm?.isEnabled && this.patchObjectType ? pm : undefined;
@ -124,11 +185,16 @@ export default class ModelWithPatches<T extends ObjectHash = any, S = SetOptions
syncDraftToState(draft, afterState);
});
if (patches.length || inversePatches.length) {
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(patches, prefix));
activePatch.reverseChanges.push(...normalizePatchPaths(inversePatches, prefix));
activePatch.changes.push(...normalizePatchPaths(nextPatches, prefix));
// Reverse changes should be applied in reverse order.
activePatch.reverseChanges.unshift(...normalizePatchPaths(nextInversePatches, prefix));
}
return result;

2
packages/core/src/patch_manager/index.ts

@ -180,7 +180,7 @@ export default class PatchManager {
});
}
private withSuppressedTracking<T>(cb: () => T): T {
withSuppressedTracking<T>(cb: () => T): T {
const prevSuppress = this.suppressTracking;
this.suppressTracking = true;

222
packages/core/src/utils/fractionalIndex.ts

@ -0,0 +1,222 @@
// Based on rocicorp/fractional-indexing (CC0)
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
function midpoint(a: string, b: string | null | undefined, digits: string): string {
const zero = digits[0];
if (b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a.slice(-1) === zero || (b && b.slice(-1) === zero)) {
throw new Error('trailing zero');
}
if (b) {
let n = 0;
while ((a[n] || zero) === b[n]) {
n++;
}
if (n > 0) {
return b.slice(0, n) + midpoint(a.slice(n), b.slice(n), digits);
}
}
const digitA = a ? digits.indexOf(a[0]) : 0;
const digitB = b != null ? digits.indexOf(b[0]) : digits.length;
if (digitB - digitA > 1) {
const midDigit = Math.round(0.5 * (digitA + digitB));
return digits[midDigit];
} else {
if (b && b.length > 1) {
return b.slice(0, 1);
} else {
return digits[digitA] + midpoint(a.slice(1), null, digits);
}
}
}
function getIntegerLength(head: string): number {
if (head >= 'a' && head <= 'z') {
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
} else if (head >= 'A' && head <= 'Z') {
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
}
throw new Error(`invalid order key head: ${head}`);
}
function validateInteger(int: string): void {
if (int.length !== getIntegerLength(int[0])) {
throw new Error(`invalid integer part of order key: ${int}`);
}
}
function getIntegerPart(key: string): string {
const integerPartLength = getIntegerLength(key[0]);
if (integerPartLength > key.length) {
throw new Error(`invalid order key: ${key}`);
}
return key.slice(0, integerPartLength);
}
function validateOrderKey(key: string, digits: string): void {
if (key === `A${digits[0].repeat(26)}`) {
throw new Error(`invalid order key: ${key}`);
}
const i = getIntegerPart(key);
const f = key.slice(i.length);
if (f.slice(-1) === digits[0]) {
throw new Error(`invalid order key: ${key}`);
}
}
function incrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split('');
let carry = true;
for (let i = digs.length - 1; carry && i >= 0; i--) {
const d = digits.indexOf(digs[i]) + 1;
if (d === digits.length) {
digs[i] = digits[0];
} else {
digs[i] = digits[d];
carry = false;
}
}
if (carry) {
if (head === 'Z') {
return `a${digits[0]}`;
}
if (head === 'z') {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) + 1);
if (h > 'a') {
digs.push(digits[0]);
} else {
digs.pop();
}
return h + digs.join('');
}
return head + digs.join('');
}
function decrementInteger(x: string, digits: string): string | null {
validateInteger(x);
const [head, ...digs] = x.split('');
let borrow = true;
for (let i = digs.length - 1; borrow && i >= 0; i--) {
const d = digits.indexOf(digs[i]) - 1;
if (d === -1) {
digs[i] = digits.slice(-1);
} else {
digs[i] = digits[d];
borrow = false;
}
}
if (borrow) {
if (head === 'a') {
return `Z${digits.slice(-1)}`;
}
if (head === 'A') {
return null;
}
const h = String.fromCharCode(head.charCodeAt(0) - 1);
if (h < 'Z') {
digs.push(digits.slice(-1));
} else {
digs.pop();
}
return h + digs.join('');
}
return head + digs.join('');
}
export function generateKeyBetween(
a: string | null | undefined,
b: string | null | undefined,
digits = BASE_62_DIGITS,
): string {
if (a != null) {
validateOrderKey(a, digits);
}
if (b != null) {
validateOrderKey(b, digits);
}
if (a != null && b != null && a >= b) {
throw new Error(`${a} >= ${b}`);
}
if (a == null) {
if (b == null) {
return `a${digits[0]}`;
}
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ib === `A${digits[0].repeat(26)}`) {
return ib + midpoint('', fb, digits);
}
if (ib < b) {
return ib;
}
const res = decrementInteger(ib, digits);
if (res == null) {
throw new Error('cannot decrement any more');
}
return res;
}
if (b == null) {
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const i = incrementInteger(ia, digits);
return i == null ? `${ia}${midpoint(fa, null, digits)}` : i;
}
const ia = getIntegerPart(a);
const fa = a.slice(ia.length);
const ib = getIntegerPart(b);
const fb = b.slice(ib.length);
if (ia === ib) {
return `${ia}${midpoint(fa, fb, digits)}`;
}
const i = incrementInteger(ia, digits);
if (i == null) {
throw new Error('cannot increment any more');
}
if (i < b) {
return i;
}
return `${ia}${midpoint(fa, null, digits)}`;
}
export function generateNKeysBetween(
a: string | null | undefined,
b: string | null | undefined,
n: number,
digits = BASE_62_DIGITS,
): string[] {
if (n === 0) {
return [];
}
if (n === 1) {
return [generateKeyBetween(a, b, digits)];
}
if (b == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(c, b, digits);
result.push(c);
}
return result;
}
if (a == null) {
let c = generateKeyBetween(a, b, digits);
const result = [c];
for (let i = 0; i < n - 1; i++) {
c = generateKeyBetween(a, c, digits);
result.push(c);
}
result.reverse();
return result;
}
const mid = Math.floor(n / 2);
const c = generateKeyBetween(a, b, digits);
return [...generateNKeysBetween(a, c, mid, digits), c, ...generateNKeysBetween(c, b, n - mid - 1, digits)];
}

252
packages/core/test/specs/patch_manager/components.js

@ -0,0 +1,252 @@
import { applyPatches } from 'immer';
import Component from 'dom_components/model/Component';
import Editor from 'editor/model/Editor';
import PatchManager from 'patch_manager';
import { generateKeyBetween, generateNKeysBetween } from 'utils/fractionalIndex';
import { serialize } from 'utils/mixins';
const flush = () => Promise.resolve();
const getUpdatePatches = (events) => events.filter((e) => e.event === 'patch:update').map((e) => e.payload);
const initState = (models) => ({
components: Object.fromEntries(
models.map((model) => {
const attributes = serialize(model.toJSON());
delete attributes.components;
return [model.get('uid'), { attributes }];
}),
),
});
describe('Patch tracking: nested Components order', () => {
let em;
let compOpts;
beforeEach(() => {
em = new Editor({ avoidDefaults: true, avoidInlineStyle: true });
em.Pages.onLoad();
const domc = em.Components;
compOpts = { em, componentTypes: domc.componentTypes, domc };
});
afterEach(() => {
em.destroyAll();
});
test('Does not create patches for non-storable props (toolbar/traits/status)', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
const cmp = new Component({}, compOpts);
em.Patches = pm;
events.length = 0;
cmp.set('toolbar', [{ command: 'tlb-move' }]);
cmp.set('traits', [{ type: 'text', name: 'title' }]);
cmp.set('status', 'selected');
await flush();
expect(getUpdatePatches(events)).toHaveLength(0);
});
test('Add child: records component add + order-map add patches', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
pm.createId = () => 'child-1';
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
em.Patches = pm;
parent.components().setParent(parent);
events.length = 0;
parent.append({ tagName: 'div' });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
expect(patch.changes[0]).toMatchObject({
op: 'add',
path: ['components', 'child-1'],
});
expect(patch.changes[0].value.attributes.uid).toBe('child-1');
expect(patch.changes[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)],
value: 'child-1',
});
// Undo order must remove map entry first, then the component object.
expect(patch.reverseChanges[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)],
});
expect(patch.reverseChanges[1]).toEqual({
op: 'remove',
path: ['components', 'child-1'],
});
});
test('Remove child: records order-map remove + component remove patches', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
pm.createId = () => 'child-1';
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
em.Patches = pm;
parent.components().setParent(parent);
const [child] = parent.append({ tagName: 'div' });
await flush();
events.length = 0;
parent.components().remove(child);
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
const orderKey = generateKeyBetween(null, null);
expect(patch.changes[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey],
});
expect(patch.changes[1]).toEqual({
op: 'remove',
path: ['components', 'child-1'],
});
// Undo order must re-add the component object first, then restore the order map.
expect(patch.reverseChanges[0]).toMatchObject({
op: 'add',
path: ['components', 'child-1'],
});
expect(patch.reverseChanges[0].value.attributes.uid).toBe('child-1');
expect(patch.reverseChanges[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey],
value: 'child-1',
});
});
test('Reorder within same parent updates only componentsOrder (no array index moves)', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
const [c1, c2, c3] = parent.append([{ tagName: 'div' }, { tagName: 'span' }, { tagName: 'p' }]);
c1.set('uid', 'c1');
c2.set('uid', 'c2');
c3.set('uid', 'c3');
em.Patches = pm;
parent.components().setParent(parent);
events.length = 0;
// Move c1 to the end (temporary remove + re-add).
parent.components().remove(c1, { temporary: true });
parent.components().add(c1, { at: 2 });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
const [k1, k2, k3] = generateNKeysBetween(null, null, 3);
const newKey = generateKeyBetween(k3, null);
expect(patch.changes[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', k1],
});
expect(patch.changes[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', newKey],
value: 'c1',
});
// Regression: no patches for `attributes.components` array indices.
const hasComponentsArrayPatch = patch.changes.some((ch) => {
const p = ch.path || [];
for (let i = 0; i < p.length - 1; i++) {
if (p[i] === 'attributes' && p[i + 1] === 'components') return true;
}
return false;
});
expect(hasComponentsArrayPatch).toBe(false);
});
test('Move between parents updates order maps and is undo/redo deterministic', async () => {
const events = [];
let state;
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
applyPatch: (changes) => {
state = applyPatches(state, changes);
},
});
const parentA = new Component({}, compOpts);
const parentB = new Component({}, compOpts);
parentA.set('uid', 'parentA');
parentB.set('uid', 'parentB');
const [child] = parentA.append({ tagName: 'div' });
child.set('uid', 'c1');
em.Patches = pm;
parentA.components().setParent(parentA);
parentB.components().setParent(parentB);
state = initState([parentA, parentB, child]);
const before = JSON.parse(JSON.stringify(state));
events.length = 0;
parentA.components().remove(child, { temporary: true });
parentB.components().add(child, { at: 0 });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
state = applyPatches(state, patch.changes);
const after = JSON.parse(JSON.stringify(state));
pm.undo();
expect(state).toEqual(before);
pm.redo();
expect(state).toEqual(after);
});
});
Loading…
Cancel
Save