Backbone.Undo.js

A simple Backbone undo-manager for simple apps

Download and participate

Production Version (0.2)
6 KB, Minified version
Development Version (0.2)
27 KB, Full source, commented

Watch a video tutorial

Watch this video to see how to work with Backbone.Undo.js and what's going on in the back­ground.

Features of Backbone.Undo.js

Drop it in

Unlike undo managers based on the me­men­to pat­tern Back­bone.Undo.js doesn't re­quire you to im­ple­ment spe­ci­fic func­tions. Just drop it in and you're ready to go.

Using Backbone-Events

You don't have to store() or restore() cer­tain states. Every­thing works auto­ma­ti­cally as Back­bone.Undo.js uses Back­bone-events to de­tect an un­do­able ac­tion.

Magic Fusion

Backbone.Undo.js has an in­ter­nal fea­ture called Ma­gic Fu­sion that de­tects if events were trig­gered in one flow. That way it's able to un­do and re­do all of their ac­tions at once.

Getting started

Backbone.Undo.js depends on Backbone.js which again depends on Underscore.js (or Lo-Dash.js). Make sure to include these two files before you include Backbone.Undo.js:

<script src="underscore.js"></script>
<script src="backbone.js"></script>

<script src="Backbone.Undo.js"></script>

Setting up your undo manager

Now, setting up your UndoManager is a matter of seconds: Just register the objects that should be observed and start their observation by calling startTracking() or by passing track: true on instantiation.

var myUndoManager = new Backbone.UndoManager({
    register: [app.mainModel, app.mainCollection], // pass an object or an array of objects
    track: true // changes will be tracked right away
});

Backbone.Undo.js now listens to the events those objects trigger. Thanks to Backbone-methods like previousAttributes() it's able to retrieve the objects' data before and after an event was triggered and pushes it to its internal undo stack.

Alternatively you can register your objects and start observing them independently from instantiation.

var myUndoManager = new Backbone.UndoManager;
myUndoManager.register(app.mainModel, app.mainCollection); // Pass any number of arguments
myUndoManager.startTracking(); // Start observation after instantiation

Calling undo() and redo()

After you've registered your models or collections and started tracking you just need to call undo() and redo() to undo and redo certain actions.

Demo: Textarea

The following demo application is a textarea that stores its value in a model every now and then.

Now, we want to provide this textarea with an undo manager (for the purpose of this demo let's ignore the fact that textareas usually have a native undo manager).

The textarea's model is globally accessible at demoTextarea. All we have to do is register this model and start tracking its changes.

var undoManager = new Backbone.UndoManager({
	register: demoTextarea, // The textarea's model is part of the global window-context
	track: true // changes should be tracked immediately
});

We call the undo()- and redo()-method when the user clicks on the respective button.

$(".undo-button").click(function () {
	undoManager.undo();
});
$(".redo-button").click(function () {
	undoManager.redo();
});

We're done. Our demo app now has undo- and redo-functionality and we didn't have to modify our model at all. Try it out:

Activate Magic Fusion

Demo: Taglist

The following demo application is a collection of tags. It let's you enter several tags at once by seperating them by comma. An undo manager is installed to undo and redo your changes. However, the behavior is rather unexpected:

If you add several tags to the collection at once you can only undo them separately. This is because adding several models to a collection triggers several "add"-events which result in several internal undo actions as Backbone.Undo.js creates its undo actions from the events an object triggers.

To solve this problem Backbone.Undo.js is equipped with a mechanism called Magic Fusion. This is an automatic detection procedure that can tell which undo actions belong together to undo and redo all of them at once.

All you have to do to activate Magic Fusion is pass true when you call undo or redo.

undoButton.click(function () {
	undoManager.undo(true); // Magic Fusion is activated
})
redoButton.click(function () {
	undoManager.redo(true); // Magic Fusion is activated
})

With Magic Fusion the taglist's undo- and redo-functionality behaves just as expected:

Advanced functionality

Backbone.Undo.js generates its undo actions from events the observed objects trigger. It supports the following events:

  • change
  • add
  • remove
  • reset
In some apps these events aren't sufficient enough and you need to support other ones, maybe even your own custom events. Backbone.Undo.js has an API to give you the ability to do so.

Demo: Planes

The following demo application is a collection of colored planes which can be resized and moved.

When we resize or move a plane and undo this action an unexpected behavior occurs: Even with activated Magic Fusion the undo manager doesn't undo the complete resize or the complete move but only every single step of it, because it doesn't know that all the changes are part of one long, ongoing action.

To solve this problem we need to make sure that only the states before and after a resize or move are recognized so that the steps in between are ignored.

The central event in this case is the "change" event because it's triggered whenever a plane is resized or moved and its data is turned into undoable actions, called UndoActions, inside Backbone.Undo.js.

The module responsible for creating UndoActions from events is called UndoTypes. There's an UndoType for every type of event. They are the core of Backbone.Undo.js

First, let's remove the UndoType for "change":

Backbone.UndoManager.removeUndoType("change");

Now, we can only undo adding new planes. Resizing and moving the planes around won't create any UndoActions:

The application sets the attribute "isChanging" to true when a resize or move begins and it sets it back to false when the action is over. We use this circumstance and create an UndoType specific for this change-event.

Backbone.UndoManager.addUndoType("change:isChanging", {});

An UndoType is an object with the functions "on", "undo" and "redo" and an optional "condition" property:

  • The "on" function is called when the event the UndoType is registered for gets triggered. It's called with the same arguments as a regular listener in Backbone. To generate an UndoAction that is pushed to the UndoStack it has to return an object with the properties "object" — the collection or model which triggered the event, "before" — the data before the event was triggered, "after" — the data after the event was triggered and optionally "options" — which can be whatever you need.
  • The "undo" function is for restoring the "before"-state. It gets called with the arguments "object", "before" and "after" and "options".
  • The "redo" function is for restoring the "after"-state. It gets called with the arguments "object", "before" and "after" and "options".
  • The "condition" property can be a boolean value or a function and can prevent the creation of an UndoAction by being false or returning false. If it's a function it's called with the same arguments as the "on" function.
The boilerplate of our UndoType looks like this:

Backbone.UndoManager.addUndoType("change:isChanging", {
	"on": function (model, isChanging, options) {},
	"undo": function (model, before, after, options) {},
	"redo": function (model, before, after, options) {}
});

The "on" function is called once at the beginning of a resize or move and once at the end of it, because that's when the isChanging attribute is changed. When it's called at the beginning, isChanging is true, at the end it's set to false. We store the model's data at the beginning in an external variable and return it at the end together with the other data.

var beforeCache;
Backbone.UndoManager.addUndoType("change:isChanging", {
	"on": function (model, isChanging, options) {
		if (isChanging) {
			// A resize / move has just begun
			beforeCache = model.toJSON();
		} else {
			// The resize / move has ended
			// Return this data so that an UndoAction is created from it
			return {
				"object": model, // The plane's model
				"before": beforeCache, // Its data from before the resize / move
				"after": model.toJSON() // Its current data / after the action
			}
		}
	},
	"undo": function (model, before, after, options) {},
	"redo": function (model, before, after, options) {}
});

We now have the data from before and after an action. To undo and redo an action we only need to set the model's attributes to the before or after data.

So, the complete code looks like this:

Backbone.UndoManager.removeUndoType("change");
var beforeCache;
Backbone.UndoManager.addUndoType("change:isChanging", {
	"on": function (model, isChanging, options) {
		if (isChanging) {
			beforeCache = model.toJSON();
		} else {
			return {
				"object": model,
				"before": beforeCache,
				"after": model.toJSON()
			}
		}
	},
	"undo": function (model, before, after, options) {
		model.set(before);
	},
	"redo": function (model, before, after, options) {
		model.set(after);
	}
});

By suspending the regular "change"-UndoType and adding a new one we modified our undo manager's behavior to meet the user's expectations. The demo application now has the anticipated undo behavior.

The demonstrated way is not the only way of solving this issue. Other solutions are possible, for example using timeouts. Nevertheless, in any case the UndoTypes-API is the way to go.

It's also possible to add an UndoType for a specific instance of Backbone.UndoManager instead of using the global approach presented here. By modifying the UndoTypes for specific instances you can have several undo managers and adjust their behavior to the events and needs of the objects they observe. With the merge()-function you can combine them again in one single undo manager to have different, adjusted behavior, but only one main undo manager to call undo() and redo() on.

To read more about that and the UndoTypes-API in general click here.

API and documentation

A complete overview of the API is included in the README.md file in the Github repository. Click here to read it.