Skip to content

ArthurClemens/JavaScript-Undo-Manager

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

68 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Undo Manager

Simple undo manager to provide undo and redo actions in JavaScript applications.

Demos

Installation

npm install undo-manager

Example

Actions (typing a character, moving an object) are structured as command pairs: one command for destruction (undo) and one for creation (redo). Each pair is added to the undo stack:

const undoManager = new UndoManager();
undoManager.add({
  undo: function() {
    // ...
  },
  redo: function() {
    // ...
  }
});

To make an action undoable, you'd add an undo/redo command pair to the undo manager:

const undoManager = new UndoManager();
const people = {}; 

function addPerson(id, name) {
  people[id] = name;
};

function removePerson(id) {
  delete people[id];
};

function createPerson(id, name) {
  // first creation
  addPerson(id, name);

  // make undoable
  undoManager.add({
    undo: () => removePerson(id),
    redo: () => addPerson(id, name)
  });
}

createPerson(101, "John");
createPerson(102, "Mary");

console.log(people); // logs: {101: "John", 102: "Mary"}

undoManager.undo();
console.log(people); // logs: {101: "John"}

undoManager.undo();
console.log(people); // logs: {}

undoManager.redo();
console.log(people); // logs: {101: "John"}

Updating the UI

TL;DR UI that relies on undo manager state - for example hasUndo and hasRedo - needs to be updated using the callback function provided with setCallback. This ensures that all internal state has been resolved before the UI is repainted.

Let's say we have an update function that conditionally disables the undo and redo buttons:

function updateUI() {
  btn_undo.disabled = !undoManager.hasUndo();
  btn_redo.disabled = !undoManager.hasRedo();
}

You might be inclined to call the update in the undo/redo command pair:

// wrong approach, don't copy
const undoManager = new UndoManager();
const states = [];

function updateState(newState) {
  states.push(newState);
  updateUI();

  undoManager.add({
    undo: function () {
      states.pop();
      updateUI(); // <= this will lead to inconsistent UI state
    },
    redo: function () {
      states.push(newState);
      updateUI(); // <= this will lead to inconsistent UI state
    }
  });
}

Instead, pass the update function to setCallback:

// recommended approach
const undoManager = new UndoManager();
undoManager.setCallback(updateUI);

const states = [];

function updateState(newState) {
  states.push(newState);
  updateUI();

  undoManager.add({
    undo: function () {
      states.pop();
    },
    redo: function () {
      states.push(newState);
    }
  });
}

Methods

add

Adds an undo/redo command pair to the stack.

function createPerson(id, name) {
  // first creation
  addPerson(id, name);

  // make undoable
  undoManager.add({
    undo: () => removePerson(id),
    redo: () => addPerson(id, name)
  });
}

Optionally add a groupId to identify related command pairs. Undo and redo actions will then be performed on all adjacent command pairs with that group id.

undoManager.add({
  groupId: 'auth',
  undo: () => removePerson(id),
  redo: () => addPerson(id, name)
});

undo

Performs the undo action.

undoManager.undo();

If a groupId was set, the undo action will be performed on all adjacent command pairs with that group id.

redo

Performs the redo action.

undoManager.redo();

If a groupId was set, the redo action will be performed on all adjacent command pairs with that group id.

clear

Clears all stored states.

undoManager.clear();

setLimit

Set the maximum number of undo steps. Default: 0 (unlimited).

undoManager.setLimit(limit);

hasUndo

Tests if any undo actions exist.

const hasUndo = undoManager.hasUndo();

hasRedo

Tests if any redo actions exist.

const hasRedo = undoManager.hasRedo();

setCallback

Get notified on changes. Pass a function to be called on undo and redo actions.

undoManager.setCallback(myCallback);

getIndex

Returns the index of the actions list.

const index = undoManager.getIndex();

getCommands

Returns the list of queued commands, optionally filtered by group id.

const commands = undoManager.getCommands();
const commands = undoManager.getCommands(groupId);

Use with CommonJS

npm install undo-manager
const UndoManager = require('undo-manager')

If you only need a single instance of UndoManager throughout your application, it may be wise to create a module that exports a singleton:

// undoManager.js
const undoManager = require('undo-manager'); // require the lib from node_modules
let singleton = undefined;

if (!singleton) {
  singleton = new undoManager();
}

module.exports = singleton;

Then in your app:

// app.js
const undoManager = require('undoManager');

undoManager.add(...);
undoManager.undo();

Use with RequireJS

If you are using RequireJS, you need to use the shim config parameter.

Assuming require.js and domReady.js are located in js/extern, the index.html load call would be:

<script src="js/extern/require.js" data-main="js/demo"></script>

And demo.js would look like this:

requirejs.config({
  baseUrl: "js",
  paths: {
    domReady: "extern/domReady",
    app: "../demo",
    undomanager: "../../js/undomanager",
    circledrawer: "circledrawer"
  },
  shim: {
    "undomanager": {
      exports: "UndoManager"
    },
    "circledrawer": {
      exports: "CircleDrawer"
    }
  }
});

require(["domReady", "undomanager", "circledrawer"], function(domReady, UndoManager, CircleDrawer) {
  "use strict";

  let undoManager,
    circleDrawer,
    btnUndo,
    btnRedo,
    btnClear;

  undoManager = new UndoManager();
  circleDrawer = new CircleDrawer("view", undoManager);

  // etcetera
});