Initial Commit

An extremely simple Backbone-Undo-Manager
This commit is contained in:
Oliver Sartun 2013-05-27 17:52:45 +02:00
commit cc0aa25232

334
Backbone.Undo.js Normal file
View File

@ -0,0 +1,334 @@
(function (win, doc, $, _, Backbone, undefined) {
function apply (fn, ctx, args) {
// As call is faster than apply, this is a faster version of apply as it uses call
return args.length <= 3 ?
fn.call(ctx, args[0], args[1], args[2]) :
fn.apply(ctx, args);
}
function hasKeys (obj, keys) {
// Checks if an object has one or more specific keys. The keys don't have to be an owned property
if (obj == null) return false;
if (!_.isArray(keys)) {
keys = slice(arguments, 1);
}
for (var i = 0, l = keys.length; i < l; i++) {
if (!(keys[i] in obj)) return false;
}
return true;
}
var getCurrentCycleIndex = (function () {
// If you add several models to a collection or set several
// attributes on a model all in sequence and yet all for
// example in one function, then several Undo-Actions are
// generated.
// If you want to undo your last Action, then only the last
// model would be removed from the collection or the last
// set attribute would be changed back to its previous value.
// To prevent that we have to figure out a way to combine
// all those actions which happend "at the same time".
// Timestamps aren't exact enough. A complex routine could
// run several milliseconds and in that time produce a lot
// of actions with different timestamps.
// Instead we take advantage of the single-threadedness of
// JavaScript:
var cycleWasIndexed = false, cycleIndex = -1;
function indexCycle() {
cycleIndex++;
cycleWasIndexed = true;
_.defer(function () {
// Here comes the magic. With a Timeout of 0
// milliseconds this function gets called whenever
// the current thread is finished
cycleWasIndexed = false;
})
}
return function () {
if (!cycleWasIndexed) {
indexCycle();
}
return cycleIndex|0;
}
})();
// To prevent binding a listener several times to one object, we register the objects
function ObjectRegistry () {
this.registeredObjects = [];
}
ObjectRegistry.prototype = {
isRegistered: function (obj) {
return obj && obj.cid ? this.registeredObjects[obj.cid] : _.contains(this.registeredObjects, obj);
},
register: function (obj) {
if (obj && obj.cid) {
this.registeredObjects[obj.cid] = obj;
} else {
this.registeredObjects.push(obj);
}
},
unregister: function (obj) {
if (obj && obj.cid) {
delete this.registeredObjects[obj.cid];
} else {
var i = _.indexOf(this.registeredObjects, obj);
if (i > -1) {
this.registeredObjects.splice(i, 1);
}
}
}
}
function onoff(which, objects, fn, ctx) {
// Binds or unbinds the "all" listener for one or more objects
for (var i = 0, l = objects.length, obj; i < l; i++) {
obj = objects[i];
if (which === "on") {
if (ctx.objectRegistry.isRegistered(obj)) {
continue;
} else {
ctx.objectRegistry.register(obj);
}
} else {
if (!ctx.objectRegistry.isRegistered(obj)) {
continue;
} else {
ctx.objectRegistry.unregister(obj);
}
}
if (_.isFunction(obj[which])) {
obj[which]("all", fn, ctx);
}
}
}
function actionUndoRedo (which, attr) {
// Calls the undo/redo-function for a specific action
var type = attr.type, fn = !UndoTypes[type] || UndoTypes[type][which];
if (_.isFunction(fn)) {
fn(attr.object, attr.before, attr.after, attr);
}
}
function managerUndoRedo (which, stack) {
// Undoes or redoes the action the pointer is pointing at
if (stack.isCurrentlyUndoRedoing ||
(which === "undo" && stack.pointer === -1) ||
(which === "redo" && stack.pointer === stack.length - 1)) {
return;
}
stack.isCurrentlyUndoRedoing = true;
var action, actions, isUndo = which === "undo";
if (isUndo) {
action = stack.at(stack.pointer);
stack.pointer--;
} else {
stack.pointer++;
action = stack.at(stack.pointer);
}
actions = stack.where({"cycleIndex": action.get("cycleIndex")});
stack.pointer += (isUndo ? -1 : 1) * (actions.length - 1);
while (action = isUndo ? actions.pop() : actions.shift()) {
action[which]();
}
stack.isCurrentlyUndoRedoing = false;
}
function addToStack(stack, type, args, maximumStackLength) {
// Adds an Undo-Action to the stack.
if (stack.track && !stack.isCurrentlyUndoRedoing && type in UndoTypes) {
var res = apply(UndoTypes[type]["on"], null, args), diff;
if (hasKeys(res, "object", "before", "after")) {
res.type = type;
res.cycleIndex = getCurrentCycleIndex();
if (stack.pointer < stack.length - 1) {
// New Actions must always be added to the end of the stack
// If the pointer is not pointed to the last action in the
// stack, presumably because actions were undone before, then
// all following actions must be discarded
var diff = stack.length - stack.pointer - 1;
while (diff--) {
stack.pop();
}
}
stack.pointer = stack.length;
stack.add(res);
if (stack.length > maximumStackLength) {
stack.shift();
stack.pointer--;
}
}
}
}
var
core_slice = Array.prototype.slice,
slice = function (arr, index) {
return core_slice.call(arr, index);
},
UndoTypes = {
"add": {
"undo": function (collection, ignore, model, data) {
// Undo add = remove
collection.remove(model, data.options);
},
"redo": function (collection, ignore, model, data) {
// Redo add = add
var options = data.options;
if (options.index) {
options.at = options.index;
}
collection.add(model, data.options);
},
"on": function (model, collection, options) {
return {
object: collection,
before: undefined,
after: model,
options: _.clone(options)
};
}
},
"remove": {
"undo": function (collection, model, ignore, data) {
var options = data.options;
if (options.index) {
options.at = options.index;
}
collection.add(model, options);
},
"redo": function (collection, model, ignore, data) {
collection.remove(model, data.options);
},
"on": function (model, collection, options) {
return {
object: collection,
before: model,
after: undefined,
options: _.clone(options)
};
}
},
"change": {
"undo": function (model, before, after) {
if (_.isEmpty(before)) {
_.each(_.keys(after), model.unset, model);
} else {
model.set(before);
}
},
"redo": function (model, before, after) {
if (_.isEmpty(after)) {
_.each(_.keys(before), model.unset, model);
} else {
model.set(after);
}
},
"on": function (model, options) {
var
changedAttributes = model.changedAttributes(),
previousAttributes = _.pick(model.previousAttributes(), _.keys(changedAttributes));
return {
object: model,
before: previousAttributes,
after: changedAttributes
}
}
},
"reset": {
"undo": function (collection, before, after) {
collection.reset(before);
},
"redo": function (collection, before, after) {
collection.reset(after);
},
"on": function (collection, options) {
return {
object: collection,
before: options.previousModels,
after: _.clone(collection.models)
};
}
}
},
Action = Backbone.Model.extend({
defaults: {
type: null, // "add", "change", etc.
object: null, // The object on which the action occured
before: null, // The previous values which were changed with this action
after: null, // The values after this action
cycleIndex: null // The cycle index is to combine all actions which happend "at once" to undo/redo them altogether
},
undo: function () {
actionUndoRedo("undo", this.attributes);
},
redo: function () {
actionUndoRedo("redo", this.attributes);
}
}),
UndoStack = Backbone.Collection.extend({
model: Action,
pointer: -1, // The pointer indicates the index where we are within the stack. We start at -1
track: false,
isCurrentlyUndoRedoing: false,
maximumStackLength: Infinity,
initialize: function () {
this.objectRegistry = new ObjectRegistry();
},
setMaxLength: function (val) {
this.maximumStackLength = val;
},
addToStack: function (type) {
addToStack(this, type, slice(arguments, 1), this.maximumStackLength);
}
}),
UndoManager = Backbone.Model.extend({
defaults: {
maximumStackLength: Infinity
},
initialize: function (attr) {
this.stack = new UndoStack;
// sync the maximumStackLength attribute with our stack
this.stack.setMaxLength(this.get("maximumStackLength"));
this.on("change:maximumStackLength", function (model, value) {
this.stack.setMaxLength(value);
}, this);
},
startTracking: function () {
this.stack.track = true;
},
stopTracking: function () {
this.stack.track = false;
},
register: function () {
onoff("on", arguments, this.stack.addToStack, this.stack);
},
unregister: function () {
onoff("off", arguments, this.stack.addToStack, this.stack);
},
undo: function () {
managerUndoRedo("undo", this.stack);
},
redo: function () {
managerUndoRedo("redo", this.stack);
}
});
UndoManager.addUndoType = function (type, fns) {
if (typeof type === "object") {
return _.each(type, function (val, key) {
UndoManager.addUndoType(key, val);
})
}
if (_.isString(type) && hasKeys(fns, "undo", "redo", "on") && _.all(fns, _.isFunction())) {
UndoTypes[type] = fns;
}
}
Backbone.UndoManager = UndoManager;
})(window, window.document, window.jQuery, window._, window.Backbone)