grapesjs-clot
Version:
Free and Open Source Web Builder Framework
405 lines (370 loc) • 10.2 kB
JavaScript
/**
* 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(`<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}
*/
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 = {};
},
};
};