Undo/Redo in JavaScript

Published November 21, 2012

Undo and redo is a fairly interesting and surprisingly difficult concept to implement in an application. Thanks to the way JavaScript treats functions, it isn't too hard to come up with a general and powerful approach for JavaScript apps.

We really just need a command stack and a set of actions we want to perform. We can wrap up the details in closures.

Here's a bare bones command stack implementation:

/**
  * @constructor
  */
Commands = function() {
    this.undoStack = [];
    this.redoStack = [];
};

/**
  * Executes an action and adds it to the undo stack.
  * @param {function()} action Action function.
  * @param {function()} reverse Reverse function.
  * @param {Object=} ctx The 'this' argument for the action/reverse functions.
  */
Commands.prototype.execute = function(action, reverse, ctx) {
    this.undoStack.push( {action: action, reverse: reverse, ctx: ctx} );
    action.call(ctx);
    this.redoStack.length = 0;
};

Commands.prototype.undo = function() {
    var c = this.undoStack.pop();
    if (c) {
        c.reverse.call(c.ctx);
        this.redoStack.push(c);
    }
};

Commands.prototype.redo = function() {
    var c = this.redoStack.pop();
    if (c) {
        c.action.call(c.ctx);
        this.undoStack.push(c);
    }
};

The third argument of execute(), ctx, is just a context for the functions to execute with. If you're not familiar, that means you can pass in a reference to a class method and then set ctx to an instance of the class, and it will all work fine. It sets the 'this' value for the function. It's optional.

Now we need some actual action functions. Your functions might be to add an item to a basket, to draw a line between two points, or anything. For ease of demonstration, I've chosen add and subtract. The important thing is that for every action you want to perform, you should have an opposite action defined. Add item to basket/remove item from basket. Or at least, a way to roll back the state (perhaps not a direct reverse operation, perhaps clone the state then reapply it).

function add(n1, n2) {
    return n1 + n2;
}

function subtract(n1, n2) {
    return n1 - n2;
}

And here's how we start executing, undoing, and redoing actions:

var number = 1,
    commands = new Commands();

commands.execute( 
  function() { number = add(number, 5) },
  function() { number = subtract(number, 5) }
);
console.log(number); // 6

commands.execute( 
  function() { number = subtract(number, 2) },
  function() { number = add(number, 2) }
);
console.log(number); // 4

commands.undo();
console.log(number); // 6

commands.undo();
console.log(number); // 1


commands.redo();
console.log(number); // 6

commands.redo();
console.log(number); // 4

That's pretty simple. As a thought exercise, what if addItemToBasket(item) returned an ID which had to be passed to removeItemFromBasketById(id)? Is that hard to handle? Turns out it's not:

var item,
    basketItemId;

commands.execute(
  function() { basketItemId = addItemToBasket(item); },
  function() { removeItemFromBasketById(basketItemId); basketItemId = null; }
);

// NOTE: at this point, basketItemId will have a proper value, supplied 
// by addItemToBasket(). Therefore, you can:

return basketItemId;

The closure scope handles it for us, and the stack structure ensures that basketItemId will always be current. So we can use this approach to have our program functions arranged mostly naturally, and then take advantage of closures and scoping to wrap them up for the undo/redo stack.

Filed under: javascript, programming

Talk is cheap

Tommy
There's a version of this that uses the HTML5 canvas, which is interesting http://www.flyingtophat.co.uk/blog/2014/02/25/undo-functionality.html

– 12:35:03 12th May 2014

mark
[Admin]
@Tommy:

That's interesting, but it trades the reversibility requirement for simply re-executing the stack from the beginning when undo is needed. That's not necessarily a better or worse idea, it depends on your situation. For small, short lived pages, or apps where reversibility is hard (like graphics) it's probably a good trade off to simply throw away the reversibility.

– 16:22:50 12th May 2014

Leave a comment:

HTML is not valid. Use:
[url=http://www.google.com]Google[/url] [b]bold[/b] [i]italics[/i] [u]underline[/u] [code]code[/code]
'