diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index f62832eaf..642ee5de9 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -213,7 +213,14 @@ Component> { }); removed.removed(); removed.trigger('removed'); - em.trigger(ComponentsEvents.remove, removed); + const collection = coll || removed.prevColl || this; + const eventOpts = { ...opts, collection }; + if (typeof eventOpts.index !== 'number') { + const prevModels = (opts.previousModels || []) as Component[]; + const idx = prevModels.indexOf(removed); + eventOpts.index = idx >= 0 ? idx : collection?.indexOf?.(removed); + } + em.trigger(ComponentsEvents.remove, removed, eventOpts); if (domc && isSymbolInstance(removed) && isSymbolRoot(removed)) { domc.symbols.__trgEvent(domc.events.symbolInstanceRemove, { component: removed }, true); @@ -397,7 +404,7 @@ Component> { return model; } - onAdd(model: Component, c?: any, opts: { temporary?: boolean } = {}) { + onAdd(model: Component, c?: any, opts: { temporary?: boolean; at?: number } = {}) { const { domc, em } = this; const avoidInline = em.config.avoidInlineStyle; domc && domc.Component.ensureInList(model); @@ -417,7 +424,10 @@ Component> { if (em && !opts.temporary) { const triggerAdd = (model: Component) => { - em.trigger(ComponentsEvents.add, model, opts); + const coll = model.collection || model.parent()?.components() || c; + const at = typeof opts.at === 'number' && coll === c ? opts.at : coll?.indexOf?.(model); + const eventOpts = { ...opts, at, collection: coll }; + em.trigger(ComponentsEvents.add, model, eventOpts); model.components().forEach((comp) => triggerAdd(comp)); }; triggerAdd(model); diff --git a/packages/core/src/editor/config/config.ts b/packages/core/src/editor/config/config.ts index 6a3f81cdc..e0a126798 100644 --- a/packages/core/src/editor/config/config.ts +++ b/packages/core/src/editor/config/config.ts @@ -15,6 +15,7 @@ import { RichTextEditorConfig } from '../../rich_text_editor/config/config'; import { SelectorManagerConfig } from '../../selector_manager/config/config'; import { StorageManagerConfig } from '../../storage_manager/config/config'; import { UndoManagerConfig } from '../../undo_manager/config'; +import { PatchManagerConfig } from '../../patch_manager/types'; import { Plugin } from '../../plugin_manager'; import { TraitManagerConfig } from '../../trait_manager/config/config'; import { CommandsConfig } from '../../commands/config/config'; @@ -306,6 +307,11 @@ export interface EditorConfig { */ undoManager?: UndoManagerConfig | boolean; + /** + * Configurations for Patches manager + */ + patches?: PatchManagerConfig | boolean; + /** * Configurations for Asset Manager. */ @@ -486,6 +492,7 @@ const config: () => EditorConfig = () => ({ }, i18n: {}, undoManager: {}, + patches: {}, assetManager: {}, canvas: {}, layerManager: {}, diff --git a/packages/core/src/editor/index.ts b/packages/core/src/editor/index.ts index 24fb7a9b3..04e2639c8 100644 --- a/packages/core/src/editor/index.ts +++ b/packages/core/src/editor/index.ts @@ -75,6 +75,7 @@ import StorageManager, { ProjectData, StorageOptions } from '../storage_manager' import StyleManager from '../style_manager'; import TraitManager from '../trait_manager'; import UndoManagerModule from '../undo_manager'; +import PatchManager from '../patch_manager'; import UtilsModule from '../utils'; import html from '../utils/html'; import defConfig, { EditorConfig, EditorConfigKeys } from './config/config'; @@ -152,6 +153,9 @@ export default class Editor implements IBaseModule { get UndoManager(): UndoManagerModule { return this.em.UndoManager; } + get Patches(): PatchManager { + return this.em.Patches; + } get RichTextEditor(): RichTextEditorModule { return this.em.RichTextEditor; } diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 1dc90fee5..671a63191 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/packages/core/src/editor/model/Editor.ts @@ -43,6 +43,7 @@ import { AddComponentsOption, ComponentAdd, DragMode } from '../../dom_component import ComponentWrapper from '../../dom_components/model/ComponentWrapper'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import DataSourceManager from '../../data_sources'; +import PatchManager from '../../patch_manager'; import { ComponentsEvents } from '../../dom_components/types'; import { InitEditorConfig } from '../..'; import { EditorEvents, SelectComponentOptions } from '../types'; @@ -54,6 +55,7 @@ const deps: (new (em: EditorModel) => IModule)[] = [ I18nModule, KeymapsModule, UndoManagerModule, + PatchManager, StorageManager, DeviceManager, ParserModule, @@ -242,6 +244,10 @@ export default class EditorModel extends Model { return this.get('DataSources'); } + get Patches(): PatchManager { + return this.get('Patches'); + } + constructor(conf: EditorConfig = {}) { super(); this._config = conf; @@ -480,6 +486,8 @@ export default class EditorModel extends Model { } changesUp(opts: any, data: Record) { + if (this.__skip) return; + this.Patches?.handleChange(data, opts); this.handleUpdates(opts, data); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c7f532747..c485269d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -159,5 +159,7 @@ export type { DataConditionProps, ExpressionProps, } from './data_sources/model/conditional_variables/DataCondition'; +export type { default as PatchManager } from './patch_manager'; +export type { PatchProps, PatchManagerConfig, JsonPatch } from './patch_manager/types'; export default grapesjs; diff --git a/packages/core/src/patch_manager/index.ts b/packages/core/src/patch_manager/index.ts new file mode 100644 index 000000000..fee7cf491 --- /dev/null +++ b/packages/core/src/patch_manager/index.ts @@ -0,0 +1,540 @@ +import Component from '../dom_components/model/Component'; +import Components from '../dom_components/model/Components'; +import { ComponentsEvents } from '../dom_components/types'; +import CssRule from '../css_composer/model/CssRule'; +import { ItemManagerModule } from '../abstract/Module'; +import { Collection } from '../common'; +import EditorModel from '../editor/model/Editor'; +import { EditorEvents } from '../editor/types'; +import { createId } from '../utils/mixins'; +import type { JsonPatch, PatchManagerConfig, PatchProps } from './types'; + +const encodePointer = (segment: string) => segment.replace(/~/g, '~0').replace(/\//g, '~1'); + +export default class PatchManager extends ItemManagerModule { + storageKey = ''; + isEnabled = false; + private debug = false; + private isReady = false; + + private history: PatchProps[] = []; + private index = -1; + private active: PatchProps | null = null; + private coalesceTimer?: ReturnType; + private coalesceMs = 0; + private maxHistory = 500; + private isApplyingExternal = false; + + private internalSetOptions = { + fromUndo: true, + noUndo: true, + avoidStore: true, + _skipPatches: true, + }; + + private static blockedRootKeys = new Set(['traits', '__data_values', 'docEl', 'head', 'toolbar']); + + constructor(em: EditorModel) { + super(em, 'Patches', new Collection(), undefined, undefined, { skipListen: true }); + } + + onInit(): void { + const cfg = (this.getConfig() as any) ?? {}; + const normalized = typeof cfg === 'boolean' ? { enable: cfg } : cfg; + this.init({ enable: true, ...normalized }); + this.setupTracking(); + } + + init(cfg: PatchManagerConfig = {}) { + this.isEnabled = !!cfg.enable; + this.maxHistory = cfg.maxHistory ?? this.maxHistory; + this.coalesceMs = cfg.coalesceMs ?? 0; + this.debug = cfg.debug ?? false; + return this; + } + + private setupTracking() { + const { em } = this; + this.isReady = !!em.get('readyLoad'); + em.on('change:readyLoad', this.handleReadyLoad); + em.on(EditorEvents.projectLoad, this.handleProjectLoad); + em.on(ComponentsEvents.add, this.handleComponentAdd); + em.on(ComponentsEvents.remove, this.handleComponentRemove); + } + + private handleReadyLoad = () => { + if (!this.em.get('readyLoad')) return; + this.isReady = true; + this.resetHistory(); + this.em.off('change:readyLoad', this.handleReadyLoad); + }; + + private handleProjectLoad = () => { + this.resetHistory(); + }; + + handleChange(data: Record = {}, opts: Record = {}) { + if (!this.canTrack() || this.shouldSkipOptions(opts)) return; + const patches: JsonPatch[] = []; + const reverse: JsonPatch[] = []; + const component = data.component as Component | undefined; + const changed = data.changed as Record | undefined; + const rule = data.rule as CssRule | undefined; + + if (component && changed) { + this.handleComponentChange(component, changed, patches, reverse); + } else if (rule && changed) { + this.handleRuleChange(rule, changed, patches, reverse); + } + + if (patches.length) { + this.collect(patches, reverse); + } + } + + private handleComponentChange( + component: Component, + changed: Record, + patches: JsonPatch[], + reverse: JsonPatch[], + ) { + const compId = component.getId(); + Object.keys(changed).forEach((key) => { + if (this.isBlockedRootKey(key)) return; + const path = this.buildPath('component', compId, [key]); + const nextVal = changed[key]; + const prevVal = component.previous ? component.previous(key) : undefined; + const { patch, inverse } = this.buildPatchPair(path, prevVal, nextVal); + patch && patches.push(patch); + inverse && reverse.push(inverse); + }); + } + + private handleRuleChange(rule: CssRule, changed: Record, patches: JsonPatch[], reverse: JsonPatch[]) { + const ruleId = (rule as any).id || rule.cid; + Object.keys(changed).forEach((key) => { + const path = this.buildPath('cssRule', `${ruleId}`, [key]); + const nextVal = changed[key]; + const prevVal = rule.previous ? rule.previous(key) : undefined; + const { patch, inverse } = this.buildPatchPair(path, prevVal, nextVal); + patch && patches.push(patch); + inverse && reverse.push(inverse); + }); + } + + private handleComponentAdd = (component: Component, opts: any = {}) => { + if (!this.canTrack() || this.shouldSkipOptions(opts)) return; + const parent = component.parent(); + const collection = (component.collection || parent?.components()) as Components | undefined; + if (!parent || !collection) return; + const at = typeof opts.at === 'number' ? opts.at : collection.indexOf(component); + const path = this.buildPath('component', parent.getId(), ['components', this.getComponentKey(collection, component, at)]); + const value = this.cloneValue(component.toJSON()); + const patch: JsonPatch = { op: 'add', path, value }; + const inverse: JsonPatch = { op: 'remove', path }; + this.collect([patch], [inverse]); + }; + + private handleComponentRemove = (component: Component, opts: any = {}) => { + if (!this.canTrack() || this.shouldSkipOptions(opts)) return; + const collection = (opts.collection || component.prevColl) as Components | undefined; + const parent = component.parent({ prev: true }); + if (!parent || !collection) return; + const index = typeof opts.index === 'number' ? opts.index : collection.indexOf(component); + const path = this.buildPath('component', parent.getId(), [ + 'components', + this.getComponentKey(collection, component, index), + ]); + const reverseVal = this.cloneValue(component.toJSON()); + const patch: JsonPatch = { op: 'remove', path }; + const inverse: JsonPatch = { op: 'add', path, value: reverseVal }; + this.collect([patch], [inverse]); + }; + + private cloneValue(value: any) { + if (typeof value === 'undefined') return value; + try { + return JSON.parse(JSON.stringify(value)); + } catch (err) { + return value; + } + } + + private buildPatchPair(path: string, prevVal: any, nextVal: any) { + const op = this.getOp(prevVal, nextVal); + const invOp = this.getOp(nextVal, prevVal); + const patch = op ? this.buildPatch(op, path, nextVal) : null; + const inverse = invOp ? this.buildPatch(invOp, path, prevVal) : null; + return { patch, inverse }; + } + + private buildPatch(op: JsonPatch['op'], path: string, value: any): JsonPatch { + const cloned = this.cloneValue(value); + return op === 'remove' ? { op, path } : { op, path, value: cloned }; + } + + private getOp(prevVal: any, nextVal: any): JsonPatch['op'] | null { + if (typeof nextVal === 'undefined') return 'remove'; + return typeof prevVal === 'undefined' ? 'add' : 'replace'; + } + + private buildPath(type: string, id: string, segments: (string | number)[] = []) { + const data = [type, id, ...segments.map((seg) => `${seg}`)]; + return `/${data.map(encodePointer).join('/')}`; + } + + private getComponentKey(coll?: Components, cmp?: Component, at?: number) { + const getFractional = (coll as any)?.getFractionalKey; + if (typeof getFractional === 'function' && cmp) { + return getFractional.call(coll, cmp); + } + if (typeof at === 'number') { + return `${at}`; + } + const idx = coll && cmp ? coll.indexOf(cmp) : -1; + return `${idx >= 0 ? idx : 0}`; + } + + private resetHistory() { + this.coalesceTimer && clearTimeout(this.coalesceTimer); + this.coalesceTimer = undefined; + this.active = null; + this.history = []; + this.index = -1; + } + + canTrack() { + return this.isEnabled && this.isReady && !this.isApplyingExternal; + } + + beginBatch(meta?: Record) { + if (!this.canTrack()) return; + if (!this.active) { + this.active = { id: createId(), ts: Date.now(), changes: [], reverseChanges: [], meta }; + this.em.trigger('patch:batch:start', this.active); + } + } + + endBatch() { + if (!this.canTrack() || !this.active) return; + const patch = this.active; + + this.active = null; + if (patch.changes.length === 0 && patch.reverseChanges.length === 0) return; + + if (this.index < this.history.length - 1) { + this.history = this.history.slice(0, this.index + 1); + } + + this.history.push(patch); + if (this.history.length > this.maxHistory) { + this.history.shift(); + } else { + this.index++; + } + + this.em.trigger('patch:update', { patch }); + if (this.debug) { + this.logWithEditor('update', patch); + } + } + + update(fn: () => void, meta?: Record) { + if (!this.canTrack()) return fn(); + + const alreadyActive = !!this.active; + + if (!alreadyActive) this.beginBatch(meta); + + try { + fn(); + } finally { + if (!alreadyActive) { + if (this.coalesceMs > 0) { + if (this.coalesceTimer) clearTimeout(this.coalesceTimer); + this.coalesceTimer = setTimeout(() => this.endBatch(), this.coalesceMs); + } else { + this.endBatch(); + } + } + } + } + + collect(changes: JsonPatch[], inverse: JsonPatch[]) { + if (!this.canTrack()) return; + + const startedHere = !this.active; + if (startedHere) this.beginBatch(); + this.active!.changes.push(...changes); + this.active!.reverseChanges.unshift(...inverse); + if (startedHere) { + if (this.coalesceMs > 0) { + if (this.coalesceTimer) clearTimeout(this.coalesceTimer); + this.coalesceTimer = setTimeout(() => this.endBatch(), this.coalesceMs); + } else { + this.endBatch(); + } + } + } + + apply(patch: PatchProps) { + if (!this.isEnabled) return; + this.isApplyingExternal = true; + try { + this.applyJsonPatchList(patch.changes); + this.em.trigger('patch:applied:external', { patch }); + if (this.debug) { + this.logWithEditor('applied external', patch); + } + } finally { + this.isApplyingExternal = false; + } + } + + undo() { + if (!this.canTrack() || this.index < 0) return; + const patch = this.history[this.index]; + + this.isApplyingExternal = true; + try { + this.applyJsonPatchList(patch.reverseChanges); + } finally { + this.isApplyingExternal = false; + } + this.index--; + this.em.trigger('patch:undo', { patch }); + } + + redo() { + if (!this.canTrack() || this.index >= this.history.length - 1) return; + const patch = this.history[this.index + 1]; + this.isApplyingExternal = true; + try { + this.applyJsonPatchList(patch.changes); + } finally { + this.isApplyingExternal = false; + } + this.index++; + this.em.trigger('patch:redo', { patch }); + } + + private applyJsonPatchList(list: JsonPatch[]) { + for (const p of list) { + try { + this.applyJsonPatch(p); + } catch (e) { + if (this.debug) { + this.logWithEditor('apply error', { patch: p } as any); + } + } + } + } + + private applyJsonPatch(p: JsonPatch) { + const seg = p.path.split('/').filter(Boolean); + const [objectType, objectId, ...rest] = seg; + if (!objectType || !objectId) return; + const target = this.resolveTarget(objectType, objectId); + if (!target) return; + + if (rest[0] === 'components' && this.applyComponentsPatch(target, rest.slice(1), p)) { + return; + } + + switch (p.op) { + case 'add': + case 'replace': + this.setByPath(target, rest, p.value); + break; + case 'remove': + this.deleteByPath(target, rest); + break; + case 'move': + this.handleMove(target, seg, p); + break; + } + } + + private applyComponentsPatch(target: any, path: string[], patch: JsonPatch) { + const coll = this.getComponentsCollection(target); + if (!coll) return false; + const [key] = path; + if (!key) return false; + + switch (patch.op) { + case 'remove': { + const model = this.findComponentByKey(coll, key); + model && coll.remove(model, { ...this.internalSetOptions }); + return true; + } + case 'add': + case 'replace': { + const index = this.resolveComponentIndex(coll, key); + const opts = { ...this.internalSetOptions, at: index }; + const existing = this.findComponentByKey(coll, key); + existing && coll.remove(existing, opts); + if (patch.value) { + const added = coll.add(patch.value as any, opts); + const list = Array.isArray(added) ? added : [added]; + list.forEach((m) => coll.setFractionalKey?.(m, key)); + } + return true; + } + case 'move': { + return this.applyComponentsMove(coll, key, patch); + } + default: + return false; + } + } + + private getComponentsCollection(target: any) { + return typeof target?.components === 'function' ? target.components() : null; + } + + private findComponentByKey(coll: any, key: string) { + if (!coll) return null; + if (typeof coll.findByFractionalKey === 'function') { + return coll.findByFractionalKey(key); + } + const idx = Number(key); + return Number.isNaN(idx) ? null : coll.at(idx); + } + + private resolveComponentIndex(coll: any, key: string) { + if (typeof coll.getIndexFromFractionalKey === 'function') { + return coll.getIndexFromFractionalKey(key); + } + const idx = Number(key); + return Number.isNaN(idx) ? coll.length : idx; + } + + private applyComponentsMove(coll: any, key: string, patch: JsonPatch) { + if (!patch.from) return false; + const fromSeg = patch.from.split('/').filter(Boolean); + const [fromType, fromId, fromLabel, fromKey] = fromSeg; + if (fromLabel !== 'components' || !fromType || !fromId || !fromKey) return false; + + const fromTarget = this.resolveTarget(fromType, fromId); + const fromColl = this.getComponentsCollection(fromTarget); + if (!fromColl) return false; + + const model = this.findComponentByKey(fromColl, fromKey); + if (!model) return false; + + fromColl.remove(model, { ...this.internalSetOptions, temporary: true }); + + const at = this.resolveComponentIndex(coll, key); + const added = coll.add(model, { ...this.internalSetOptions, at }); + const list = Array.isArray(added) ? added : [added]; + list.forEach((m) => coll.setFractionalKey?.(m, key)); + return true; + } + + private resolveTarget(type: string, id: string): any { + const { em } = this; + switch (type) { + case 'component': + return em.Components?.getById(id); + case 'cssRule': + return em.Css?.rules?.get(id) ?? em.Css?.get(id); + default: + return null; + } + } + + private isBlockedRootKey(key?: string) { + if (!key) return false; + return PatchManager.blockedRootKeys.has(key); + } + + private setByPath(target: any, path: string[], value: any) { + if (!target || !path.length) return; + const rootKey = path[0]; + if (this.isBlockedRootKey(rootKey)) return; + + if (typeof target.set === 'function') { + if (path.length === 1) { + target.set({ [rootKey]: value }, this.internalSetOptions); + } else { + const leafKey = path[path.length - 1]; + const baseKeys = path.slice(0, -1); + const baseKeyPath = baseKeys.join('.'); + let subtree = target.get(baseKeyPath) ?? target.get(baseKeys[0]) ?? {}; + const clone = Array.isArray(subtree) ? [...subtree] : { ...subtree }; + let ref = clone as any; + for (let i = 0; i < baseKeys.length - 1; i++) { + const k = baseKeys[i + 1]; + const next = ref[k]; + if (next && typeof next === 'object') { + ref[k] = Array.isArray(next) ? [...next] : { ...next }; + } else if (typeof next === 'undefined') { + ref[k] = {}; + } + ref = ref[k]; + } + ref[leafKey] = value; + if (baseKeys.length > 1) { + target.set(baseKeyPath, clone, this.internalSetOptions); + } else { + target.set(baseKeys[0], clone, this.internalSetOptions); + } + } + return; + } + + let ref = target as any; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (ref[key] == null || typeof ref[key] !== 'object') { + ref[key] = {}; + } + ref = ref[key]; + } + ref[path[path.length - 1]] = value; + } + + private deleteByPath(target: any, path: string[]) { + if (!target || !path.length) return; + const rootKey = path[0]; + if (this.isBlockedRootKey(rootKey)) return; + + if (typeof target.unset === 'function' && path.length === 1) { + target.unset(rootKey, this.internalSetOptions); + return; + } + let ref = target as any; + for (let i = 0; i < path.length - 1; i++) { + const key = path[i]; + if (!ref[key] || typeof ref[key] !== 'object') return; + ref = ref[key]; + } + delete ref[path[path.length - 1]]; + } + + private handleMove(_target: any, _seg: string[], _p: JsonPatch) {} + + destroy(): void { + this.em?.off('change:readyLoad', this.handleReadyLoad); + this.em?.off(EditorEvents.projectLoad, this.handleProjectLoad); + this.em?.off(ComponentsEvents.add, this.handleComponentAdd); + this.em?.off(ComponentsEvents.remove, this.handleComponentRemove); + this.resetHistory(); + this.isApplyingExternal = false; + super.__destroy?.(); + } + + private shouldSkipOptions(opts: Record = {}) { + return opts._skipPatches || opts.avoidStore || opts.noUndo || opts.partial || opts.temporary || opts.fromUndo; + } + + private logWithEditor(eventName: string, patch: PatchProps) { + try { + this.em.log(`[Patches] ${eventName}`, { + ns: 'patches', + level: 'debug', + patch, + }); + } catch {} + } +} diff --git a/packages/core/src/patch_manager/types.ts b/packages/core/src/patch_manager/types.ts new file mode 100644 index 000000000..c1c0b0f6b --- /dev/null +++ b/packages/core/src/patch_manager/types.ts @@ -0,0 +1,23 @@ +export type JsonPatchOp = 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; + +export interface JsonPatch { + op: JsonPatchOp; + path: string; + from?: string; + value?: any; +} + +export interface PatchProps { + id: string; + ts: number; + changes: JsonPatch[]; + reverseChanges: JsonPatch[]; + meta?: Record; +} + +export interface PatchManagerConfig { + enable?: boolean; + maxHistory?: number; + coalesceMs?: number; + debug?: boolean; +}