AngularJS Memento Factory


Edit this page on GitHub

The Memento Factory let’s you add “Undo” and “Redo” functionality to your AngularJS App. This is an easily overlooked feature that adds a “wow” moment to your user-experience. It’s also incredibly easy to implement.

In this article I explain the logic of the Factory, give a demonstration of how we use it at Accredible, and explore some more great use-cases from around the web.

A Simple Demo

Before getting into the details, I’ve created a simple demo that uses the Memento Factory to track a number of non-primite variables, ie: arrays and objects.

Demo GitHub

A trivial example of the Memento Factory

By playing with the demo, and viewing the source, you can see the Memento Factory is incredibly simple to use and effective at tracking these variables.

How Does It Work?

There are typically two design patterns for handling Undo and Redo:

The The Command Pattern requires that every action has an inverse action. This puts huge demands on your code; creating an inverse for every action is no trivial feat!

The The Memento Pattern deals only with storing and restoring state. This concept fits very well with the core idea of state in AngularJS, and so is a great fit for our factory.

function MementoFactory(){
  return function() {
    var memento = this;
    // Private properties
    var subjects = arguments; // We can track multiple objects or arrays
    var stack = []; // Each call to "save" makes a copy of every subject on the stack
    var currentIndex = 0; // The "current" position on the stack stack
    // Begin by saving the current state
    save();
    // Public properties
    memento.timestamp = null; // The timestamp for the current stack
    // Public methods
    memento.save = save;
    memento.canUndo = canUndo;
    memento.undo = undo;
    memento.canRedo = canRedo;
    memento.redo = redo;

    function save() {
      var snapshot = {
        timestamp: Date.now(), // The save time
        subjects: [], // Contains each of the subjects
      };
      for (var a = 0, al = subjects.length; a < al; a++) {
        snapshot.subjects.push(angular.copy(subjects[a]));
      }
      if (stack[currentIndex] && angular.equals(stack[currentIndex].subjects, snapshot.subjects)) {
        return; // Do nothing if the new snapshot is the same as the current snapshot
      }
      if (canRedo()) {
        stack = stack.slice(0, currentIndex + 1); // Since we can "redo" we must overwrite that timeline (consider Back To The Future: Part II)
      }
      memento.timestamp = snapshot.timestamp; // Store the time
      stack.push(snapshot);
      currentIndex = stack.length - 1;
    };
    function canUndo() {
      return currentIndex > 0;
    };
    function undo() {
      if (canUndo()) {
        restoreSnapshot(-1);
      }
    };
    function canRedo() {
      return currentIndex < stack.length - 1;
    };
    function redo() {
      if (canRedo()) {
        restoreSnapshot(+1);
      }
    };
    function restoreSnapshot(indexDiff) {
      currentIndex += indexDiff;
      var snapshot = stack[currentIndex];
      memento.timestamp = snapshot.timestamp; // Update the timestamp
      for (var s = 0, sl = snapshot.subjects.length; s < sl; s++) {
        if (snapshot.subjects[s] !== subjects[s]) {
          angular.copy(snapshot.subjects[s], subjects[s]);
        }
      }
    };
  };
};

angular
  .module('app')
  .factory('Memento', MementoFactory);

The MementoFactory exposes a few methods and properties:

Implementation

It’s very strightforward. Assuming the factory is available to your Controller or Service…

Create a Memento object

Create a new Memento(...) object, passing the non-primitive variables you want to track

ctrl.user = { name: 'David King', location: 'England' };
ctrl.tags = [ 'AngularJS', 'Angular', 'Firebase' ];
// Create a new Memento object
var memento = new Memento(ctrl.user, ctrl.tags);
// Expose the undo and redo methods
ctrl.canUndo = memento.canUndo;
ctrl.redo    = memento.redo;
ctrl.canRedo = memento.canRedo;
ctrl.undo    = memento.undo;

Add undo and redo buttons to your View

<button
  type="button"
  ng-click="ctrl.undo()"
  ng-disabled="!ctrl.canUndo">Undo</button>
<button
  type="button"
  ng-click="ctrl.redo()"
  ng-disabled="!ctrl.canRedo">Redo</button>

Save your Memento object, when appropriate

<input
  type="text"
  ng-model="ctrl.user.name"
  ng-change="ctrl.save()">
<input
  type="text"
  ng-model="ctrl.user.location"
  ng-change="ctrl.save()">

… and that’s it!

Where should I use it?

I think every single design interface should have Undo and Redo functionality. The apparent complexity of this feature is often enough to dissuade people from adding it. As I’ve demonstrated here, with AngularJS and a good Memento Factory, it’s actually trivial.

We use it at Accredible on our Certificate Design editor:

Similar Libraries

There are a number of libraries that offer similar functionality:

They’re worth exploring, they both follow the $watch paradigm, which comes at a price.