UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

398 lines (361 loc) 9.44 kB
/** * 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'; import defaults, { UndoManagerConfig } from './config'; export interface UndoGroup { index: number; actions: any[]; labels: string[]; } const hasSkip = (opts: any) => opts.avoidStore || opts.noUndo; const getChanged = (obj: any) => Object.keys(obj.changedAttributes()); export default class UndoManagerModule extends Module<UndoManagerConfig & { name?: string; _disable?: boolean }> { beforeCache?: any; um: any; 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 configuration object * @name getConfig * @function * @return {Object} */ /** * 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('<div>C1</div><div>C2</div>');` * `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<string, any> = {}; 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(); } }