diff --git a/src/undo_manager/index.js b/src/undo_manager/index.js deleted file mode 100644 index a3ffd218c..000000000 --- a/src/undo_manager/index.js +++ /dev/null @@ -1,405 +0,0 @@ -/** - * This module allows to manage the stack of changes applied in canvas. - * Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance - * - * ```js - * const um = editor.UndoManager; - * ``` - * - * * [getConfig](#getconfig) - * * [add](#add) - * * [remove](#remove) - * * [removeAll](#removeall) - * * [start](#start) - * * [stop](#stop) - * * [undo](#undo) - * * [undoAll](#undoall) - * * [redo](#redo) - * * [redoAll](#redoall) - * * [hasUndo](#hasundo) - * * [hasRedo](#hasredo) - * * [getStack](#getstack) - * * [clear](#clear) - * - * @module UndoManager - */ - -import UndoManager from 'backbone-undo'; -import { isArray, isBoolean, isEmpty, unique, times } from 'underscore'; - -export default () => { - let em; - let um; - let config; - let beforeCache; - const configDef = { - maximumStackLength: 500, - trackSelection: 1, - }; - const hasSkip = opts => opts.avoidStore || opts.noUndo; - const getChanged = obj => Object.keys(obj.changedAttributes()); - - return { - name: 'UndoManager', - - /** - * Initialize module - * @param {Object} config Configurations - * @private - */ - init(opts = {}) { - config = { ...configDef, ...opts }; - em = config.em; - this.em = em; - if (config._disable) { - config = { ...config, maximumStackLength: 0 }; - } - const fromUndo = true; - um = new UndoManager({ track: true, register: [], ...config }); - um.changeUndoType('change', { - condition: object => { - const hasUndo = object.get('_undo'); - if (hasUndo) { - const undoExc = object.get('_undoexc'); - if (isArray(undoExc)) { - if (getChanged(object).some(chn => undoExc.indexOf(chn) >= 0)) return false; - } - if (isBoolean(hasUndo)) return true; - if (isArray(hasUndo)) { - if (getChanged(object).some(chn => hasUndo.indexOf(chn) >= 0)) return true; - } - } - return false; - }, - on(object, v, opts) { - !beforeCache && (beforeCache = object.previousAttributes()); - const opt = opts || v || {}; - opt.noUndo && - setTimeout(() => { - beforeCache = null; - }); - if (hasSkip(opt)) { - return; - } else { - const after = object.toJSON({ fromUndo }); - const result = { - object, - before: beforeCache, - after, - }; - beforeCache = null; - // Skip undo in case of empty changes - if (isEmpty(after)) return; - - return result; - } - }, - }); - um.changeUndoType('add', { - on: (model, collection, options = {}) => { - if (hasSkip(options) || !this.isRegistered(collection)) return; - return { - object: collection, - before: undefined, - after: model, - options: { ...options, fromUndo }, - }; - }, - }); - um.changeUndoType('remove', { - on: (model, collection, options = {}) => { - if (hasSkip(options) || !this.isRegistered(collection)) return; - return { - object: collection, - before: model, - after: undefined, - options: { ...options, fromUndo }, - }; - }, - }); - um.changeUndoType('reset', { - undo: (collection, before) => { - collection.reset(before, { fromUndo }); - }, - redo: (collection, b, after) => { - collection.reset(after, { fromUndo }); - }, - on: (collection, options = {}) => { - if (hasSkip(options) || !this.isRegistered(collection)) return; - return { - object: collection, - before: options.previousModels, - after: [...collection.models], - options: { ...options, fromUndo }, - }; - }, - }); - - um.on('undo redo', () => { - em.trigger('change:canvasOffset'); - em.getSelectedAll().map(c => c.trigger('rerender:layer')); - }); - ['undo', 'redo'].forEach(ev => um.on(ev, () => em.trigger(ev))); - - return this; - }, - - postLoad() { - config.trackSelection && em && this.add(em.get('selected')); - }, - - /** - * Get module configurations - * @return {Object} Configuration object - * @example - * const config = um.getConfig(); - * // { ... } - */ - getConfig() { - return config; - }, - - /** - * Add an entity (Model/Collection) to track - * Note: New Components and CSSRules will be added automatically - * @param {Model|Collection} entity Entity to track - * @return {this} - * @example - * um.add(someModelOrCollection); - */ - add(entity) { - um.register(entity); - return this; - }, - - /** - * Remove and stop tracking the entity (Model/Collection) - * @param {Model|Collection} entity Entity to remove - * @return {this} - * @example - * um.remove(someModelOrCollection); - */ - remove(entity) { - um.unregister(entity); - return this; - }, - - /** - * Remove all entities - * @return {this} - * @example - * um.removeAll(); - */ - removeAll() { - um.unregisterAll(); - return this; - }, - - /** - * Start/resume tracking changes - * @return {this} - * @example - * um.start(); - */ - start() { - um.startTracking(); - return this; - }, - - /** - * Stop tracking changes - * @return {this} - * @example - * um.stop(); - */ - stop() { - um.stopTracking(); - return this; - }, - - /** - * Undo last change - * @return {this} - * @example - * um.undo(); - */ - undo(all = true) { - !em.isEditing() && um.undo(all); - return this; - }, - - /** - * Undo all changes - * @return {this} - * @example - * um.undoAll(); - */ - undoAll() { - um.undoAll(); - return this; - }, - - /** - * Redo last change - * @return {this} - * @example - * um.redo(); - */ - redo(all = true) { - !em.isEditing() && um.redo(all); - return this; - }, - - /** - * Redo all changes - * @return {this} - * @example - * um.redoAll(); - */ - redoAll() { - um.redoAll(); - return this; - }, - - /** - * Checks if exists an available undo - * @return {Boolean} - * @example - * um.hasUndo(); - */ - hasUndo() { - return um.isAvailable('undo'); - }, - - /** - * Checks if exists an available redo - * @return {Boolean} - * @example - * um.hasRedo(); - */ - hasRedo() { - return um.isAvailable('redo'); - }, - - /** - * Check if the entity (Model/Collection) to tracked - * Note: New Components and CSSRules will be added automatically - * @param {Model|Collection} entity Entity to track - * @returns {Boolean} - */ - isRegistered(obj) { - return !!this.getInstance().objectRegistry.isRegistered(obj); - }, - - /** - * Get stack of changes - * @return {Collection} - * @example - * const stack = um.getStack(); - * stack.each(item => ...); - */ - getStack() { - return um.stack; - }, - - /** - * Get grouped undo manager stack. - * The difference between `getStack` is when you do multiple operations at a time, - * like appending multiple components: - * `editor.getWrapper().append('
C1
C2
');` - * `getStack` will return a collection length of 2. - * `getStackGroup` instead will group them as a single operation (the first - * inserted component will be returned in the list) by returning an array length of 1. - * @return {Array} - * @private - */ - getStackGroup() { - const result = []; - const inserted = []; - - this.getStack().forEach(item => { - const index = item.get('magicFusionIndex'); - if (inserted.indexOf(index) < 0) { - inserted.push(index); - result.push(item); - } - }); - - return result; - }, - - skip(clb) { - this.stop(); - clb(); - this.start(); - }, - - getGroupedStack() { - const result = {}; - const stack = this.getStack(); - const createItem = (item, index) => { - const { type, after, before, object, options = {} } = item.attributes; - return { index, type, after, before, object, options }; - }; - stack.forEach((item, i) => { - const index = item.get('magicFusionIndex'); - const value = createItem(item, i); - - if (!result[index]) { - result[index] = [value]; - } else { - result[index].push(value); - } - }); - - return Object.keys(result).map(index => { - const actions = result[index]; - return { - index: actions[actions.length - 1].index, - actions, - labels: unique( - actions.reduce((res, item) => { - const label = item.options?.action; - label && res.push(label); - return res; - }, []) - ), - }; - }); - }, - - goToGroup(group) { - if (!group) return; - const current = this.getPointer(); - const goTo = group.index - current; - times(Math.abs(goTo), () => { - this[goTo < 0 ? 'undo' : 'redo'](false); - }); - }, - - getPointer() { - return this.getStack().pointer; - }, - - /** - * Clear the stack - * @return {this} - * @example - * um.clear(); - */ - clear() { - um.clear(); - return this; - }, - - getInstance() { - return um; - }, - - destroy() { - this.clear().removeAll(); - [em, um, config, beforeCache].forEach(i => (i = {})); - this.em = {}; - }, - }; -}; diff --git a/src/undo_manager/index.ts b/src/undo_manager/index.ts new file mode 100644 index 000000000..f23a3aa8f --- /dev/null +++ b/src/undo_manager/index.ts @@ -0,0 +1,410 @@ +/** + * This module allows to manage the stack of changes applied in canvas. + * Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance + * + * ```js + * const um = editor.UndoManager; + * ``` + * + * * [getConfig](#getconfig) + * * [add](#add) + * * [remove](#remove) + * * [removeAll](#removeall) + * * [start](#start) + * * [stop](#stop) + * * [undo](#undo) + * * [undoAll](#undoall) + * * [redo](#redo) + * * [redoAll](#redoall) + * * [hasUndo](#hasundo) + * * [hasRedo](#hasredo) + * * [getStack](#getstack) + * * [clear](#clear) + * + * @module UndoManager + */ +// @ts-ignore +import UndoManager from 'backbone-undo'; +import { isArray, isBoolean, isEmpty, unique, times } from 'underscore'; +import { Module } from '../abstract'; +import EditorModel from '../editor/model/Editor'; + +export interface UndoManagerConfig { + maximumStackLength?: number; + trackSelection?: boolean; +} + +export interface UndoGroup { + index: number; + actions: any[]; + labels: string[]; +} + +const defaults: UndoManagerConfig = { + maximumStackLength: 500, + trackSelection: true, +}; + +const hasSkip = (opts: any) => opts.avoidStore || opts.noUndo; + +const getChanged = (obj: any) => Object.keys(obj.changedAttributes()); + +export default class UndoManagerModule extends Module { + beforeCache?: any; + um: UndoManager; + + constructor(em: EditorModel) { + super(em, 'UndoManager', defaults); + + if (this.config._disable) { + this.config.maximumStackLength = 0; + } + + const fromUndo = true; + this.um = new UndoManager({ track: true, register: [], ...this.config }); + this.um.changeUndoType('change', { + condition: (object: any) => { + const hasUndo = object.get('_undo'); + if (hasUndo) { + const undoExc = object.get('_undoexc'); + if (isArray(undoExc)) { + if (getChanged(object).some(chn => undoExc.indexOf(chn) >= 0)) return false; + } + if (isBoolean(hasUndo)) return true; + if (isArray(hasUndo)) { + if (getChanged(object).some(chn => hasUndo.indexOf(chn) >= 0)) return true; + } + } + return false; + }, + on(object: any, v: any, opts: any) { + !this.beforeCache && (this.beforeCache = object.previousAttributes()); + const opt = opts || v || {}; + opt.noUndo && + setTimeout(() => { + this.beforeCache = null; + }); + if (hasSkip(opt)) { + return; + } else { + const after = object.toJSON({ fromUndo }); + const result = { + object, + before: this.beforeCache, + after, + }; + this.beforeCache = null; + // Skip undo in case of empty changes + if (isEmpty(after)) return; + + return result; + } + }, + }); + this.um.changeUndoType('add', { + on: (model: any, collection: any, options = {}) => { + if (hasSkip(options) || !this.isRegistered(collection)) return; + return { + object: collection, + before: undefined, + after: model, + options: { ...options, fromUndo }, + }; + }, + }); + this.um.changeUndoType('remove', { + on: (model: any, collection: any, options = {}) => { + if (hasSkip(options) || !this.isRegistered(collection)) return; + return { + object: collection, + before: model, + after: undefined, + options: { ...options, fromUndo }, + }; + }, + }); + this.um.changeUndoType('reset', { + undo: (collection: any, before: any) => { + collection.reset(before, { fromUndo }); + }, + redo: (collection: any, b: any, after: any) => { + collection.reset(after, { fromUndo }); + }, + on: (collection: any, options: any = {}) => { + if (hasSkip(options) || !this.isRegistered(collection)) return; + return { + object: collection, + before: options.previousModels, + after: [...collection.models], + options: { ...options, fromUndo }, + }; + }, + }); + + this.um.on('undo redo', () => { + em.trigger('change:canvasOffset'); + em.getSelectedAll().map(c => c.trigger('rerender:layer')); + }); + ['undo', 'redo'].forEach(ev => this.um.on(ev, () => em.trigger(ev))); + } + + postLoad() { + const { config, em } = this; + config.trackSelection && em && this.add(em.get('selected')); + } + + /** + * Get module configurations + * @return {Object} Configuration object + * @example + * const config = um.getConfig(); + * // { ... } + */ + getConfig() { + return this.config; + } + + /** + * Add an entity (Model/Collection) to track + * Note: New Components and CSSRules will be added automatically + * @param {Model|Collection} entity Entity to track + * @return {this} + * @example + * um.add(someModelOrCollection); + */ + add(entity: any) { + this.um.register(entity); + return this; + } + + /** + * Remove and stop tracking the entity (Model/Collection) + * @param {Model|Collection} entity Entity to remove + * @return {this} + * @example + * um.remove(someModelOrCollection); + */ + remove(entity: any) { + this.um.unregister(entity); + return this; + } + + /** + * Remove all entities + * @return {this} + * @example + * um.removeAll(); + */ + removeAll() { + this.um.unregisterAll(); + return this; + } + + /** + * Start/resume tracking changes + * @return {this} + * @example + * um.start(); + */ + start() { + this.um.startTracking(); + return this; + } + + /** + * Stop tracking changes + * @return {this} + * @example + * um.stop(); + */ + stop() { + this.um.stopTracking(); + return this; + } + + /** + * Undo last change + * @return {this} + * @example + * um.undo(); + */ + undo(all = true) { + const { em, um } = this; + !em.isEditing() && um.undo(all); + return this; + } + + /** + * Undo all changes + * @return {this} + * @example + * um.undoAll(); + */ + undoAll() { + this.um.undoAll(); + return this; + } + + /** + * Redo last change + * @return {this} + * @example + * um.redo(); + */ + redo(all = true) { + const { em, um } = this; + !em.isEditing() && um.redo(all); + return this; + } + + /** + * Redo all changes + * @return {this} + * @example + * um.redoAll(); + */ + redoAll() { + this.um.redoAll(); + return this; + } + + /** + * Checks if exists an available undo + * @return {Boolean} + * @example + * um.hasUndo(); + */ + hasUndo() { + return !!this.um.isAvailable('undo'); + } + + /** + * Checks if exists an available redo + * @return {Boolean} + * @example + * um.hasRedo(); + */ + hasRedo() { + return !!this.um.isAvailable('redo'); + } + + /** + * Check if the entity (Model/Collection) to tracked + * Note: New Components and CSSRules will be added automatically + * @param {Model|Collection} entity Entity to track + * @returns {Boolean} + */ + isRegistered(obj: any) { + return !!this.getInstance().objectRegistry.isRegistered(obj); + } + + /** + * Get stack of changes + * @return {Collection} + * @example + * const stack = um.getStack(); + * stack.each(item => ...); + */ + getStack(): any[] { + return this.um.stack; + } + + /** + * Get grouped undo manager stack. + * The difference between `getStack` is when you do multiple operations at a time, + * like appending multiple components: + * `editor.getWrapper().append('
C1
C2
');` + * `getStack` will return a collection length of 2. + * `getStackGroup` instead will group them as a single operation (the first + * inserted component will be returned in the list) by returning an array length of 1. + * @return {Array} + * @private + */ + getStackGroup() { + const result: any = []; + const inserted: any = []; + + this.getStack().forEach((item: any) => { + const index = item.get('magicFusionIndex'); + if (inserted.indexOf(index) < 0) { + inserted.push(index); + result.push(item); + } + }); + + return result; + } + + skip(clb: Function) { + this.stop(); + clb(); + this.start(); + } + + getGroupedStack(): UndoGroup[] { + const result: Record = {}; + const stack = this.getStack(); + const createItem = (item: any, index: number) => { + const { type, after, before, object, options = {} } = item.attributes; + return { index, type, after, before, object, options }; + }; + stack.forEach((item, i) => { + const index = item.get('magicFusionIndex'); + const value = createItem(item, i); + + if (!result[index]) { + result[index] = [value]; + } else { + result[index].push(value); + } + }); + + return Object.keys(result).map(index => { + const actions = result[index]; + return { + index: actions[actions.length - 1].index, + actions, + labels: unique( + actions.reduce((res: any, item: any) => { + const label = item.options?.action; + label && res.push(label); + return res; + }, []) + ), + }; + }); + } + + goToGroup(group: UndoGroup) { + if (!group) return; + const current = this.getPointer(); + const goTo = group.index - current; + times(Math.abs(goTo), () => { + this[goTo < 0 ? 'undo' : 'redo'](false); + }); + } + + getPointer(): number { + // @ts-ignore + return this.getStack().pointer; + } + + /** + * Clear the stack + * @return {this} + * @example + * um.clear(); + */ + clear() { + this.um.clear(); + return this; + } + + getInstance() { + return this.um; + } + + destroy() { + this.clear().removeAll(); + } +}