Miscellaneous / Undo/redo

Undo / Redo

From version 1.9.3 onwards, the Toolkit ships with an undo/redo manager. The following operations are tracked:

  • node removal
  • node addition
  • group removal
  • group addition
  • addition of a port to a node/group
  • remove of a port from a node/group
  • edge addition
  • edge removal
  • node/edge/group/port update
  • node/group move

Note that the undo/redo manager always suspends tracking when the load operation is called on the underlying Toolkit, also clearing its undo and redo stacks.

Installation

The undo/redo manager ships as a separate package, in the file jsplumbtoolkit-undo-redo.tgz. You can import it as a local file reference:

{
    "dependencies":{
        ...
        "jsplumbtoolkit-undo-redo":"file:path/to/jsplumbtoolkit-undo-redo.tgz",
        ...        
    }
}

If you're using Angular or Webpack/Rollup etc, this should suffice for it to appear in your output bundle. If you're more of a traditionalist and you're including scripts individually, you'll find it at:

<script src="./node_modules/jsplumbtoolkit-undo-redo/dist/js/jsplumbtoolkit-undo-redo.js"></script>

Setup

You'll first need to create an instance of the Toolkit. Then you can create an undo manager like this:

var undoManager = new jsPlumbToolkitUndoRedo({
    toolkit:toolkitInstance
})

OR

var undoManager = new jsPlumbToolkitUndoRedo({
    surface:surfaceWidget
})

The difference between these two is that if you pass in a Surface to the constructor then node/group move events are supported, whereas if you pass in a jsPlumbToolkit instance they are not.

Compounding Operations

When you remove a node or group from a Toolkit instance that has edges attached to it, the attached edges are first removed from the Toolkit instance. This causes individual edge removal operations to be tracked by the undo/redo manager, followed by the node/group removal operation. If you subsequently execute an undo command, the only thing that is undone is the removal of the node/group - the edge removal operations are still on the stack, and need to be undone individually. However, you can instruct the undo/redo manager to fold all of these operations into one:

var undoManager = new jsPlumbToolkitUndoRedo({
    toolkit:toolkitInstance,
    compound:true
})

With compound:true, the undo manager will fold a series of edge removals followed by a node/group removal into a single operation on the undo stack. Executing undo will reinstate the node/group and all of the edges that were removed in one operation. Executing redo subsequently will once again remove all the edges and the node/group.

Stack size

By default, a maximum of 50 operations can be stored on the undo stack. You can set this value with the maximumSize parameter:

var undoManager = new jsPlumbToolkitUndoRedo({
    toolkit:toolkitInstance,
    maximumSize:10
})

Operations available

The undo/redo manager offers the following operations:

  • undo Executes an undo of the most recent supported operation. If there is nothing in the undo queue, this method has no effect.
  • redo Executes a redo of the most recently undone operation. If there is nothing in the redo queue, this method has no effect.
  • clear Clears the undo and redo queues.
  • transaction Allows you to group a set of changes into a single entry on the undo stack, in the same way the undo manager will compound a node/port/group removal and its associated edges into a single operation.
undoManager.transaction(function() {
    toolkit.addNode({id:"1"});
    toolkit.addNode({id:"2"});
    toolkit.addEdge({source:"1", target:"2"});
});

Here, we compound the addition of two nodes and an edge connecting them into a single entry on the undo stack. Were the user to call undo on the manager at this point, both nodes and the edge would be removed.

Responding to changes

The undo/redo manager fires an event whenever its state changes, either as a result of an event from the associated Toolkit, or from an undo or redo operation executed directly on itself. To capture these events, supply an onChange function to the constructor:

var undoManager = new jsPlumbToolkitUndoRedo({
    toolkit:toolkitInstance,
    onChange:function(mgr, undoCount, redoCount) {
        // mgr is the undo manager instance
        
        // undoCount is the current size of the undo stack
        
        // redoCount is the current size of the redo stack
    }
})

Example - Toolkit demonstrations

We use the undo/redo manager in several of the Toolkit demonstrations. This example is from our [demo-layouts](Layouts demonstration). The constructor code for the undo/redo manager looks like this:

...
var controls = mainElement.querySelector(".controls");
...

var undoredo new jsPlumbToolkitUndoRedo({
    toolkit:toolkit,
    onChange:function(undo, undoSize, redoSize) {
        controls.setAttribute("can-undo", undoSize > 0);
        controls.setAttribute("can-redo", redoSize > 0);
    },
    compound:true
});

jsPlumb.on(controls, "tap", "[undo]", function () {
    undoredo.undo();
});

jsPlumb.on(controls, "tap", "[redo]", function () {
    undoredo.redo();
});

Points to note:

  • We use event compounding via compound:true to group edge removal along with node/group removal.
  • In our onChange listener we set an attribute for each of the undo and redo cases on the element in which our undo/redo buttons reside.

The HTML for the controls component looks like this when the page loads:

<div class="controls">
    ...
    <i class="fa fa-undo" undo title="Undo last action"></i>
    <i class="fa fa-repeat" redo title="Redo last action"></i>
    ...
</div>

Upon receipt of an event from the undo/redo manager, a can-undo and can-redo attribute is written onto this element, for example:

<div class="controls" can-undo="true" can-redo="false">
    ...
    <i class="fa fa-undo" undo title="Undo last action"></i>
    <i class="fa fa-repeat" redo title="Redo last action"></i>
    ...
</div>

We couple this arrangement with the following CSS:

[undo], [redo] { background-color:darkgray !important; }
[can-undo='true'] [undo], [can-redo='true'] [redo] { background-color: #3E7E9C  !important; }

It's a simple setup where by default the undo/redo buttons are gray, switching to blue when their parent has the appropriate attribute set. They're never actually disabled in our demonstrations; they're not actually buttons, and it doesn't matter anyway, as calling undo or redo on an instance of the undo/redo manager when there is nothing on the relevant stack has no effect.

Compounding operations

We set compound:true on the undo manager, so that it will automatically group removal operations together. But in this demonstration we also manually compound operations, which is worth discussing. When you click the X button on some node, we actually delete that node and all of its descendants. The code originally looked like this:

jsPlumb.on(canvasElement, "tap", ".delete", function (e) {
    var info = toolkit.getObjectInfo(this);
    var selection = toolkit.selectDescendants(info.obj, true);
    toolkit.remove(selection);
});

..we identify the clicked element, get a selection containing that node and all its descendants, and then tell the Toolkit to remove everything in that selection.

Since adding the undo/redo manager to this demonstration, the code now looks like this:

jsPlumb.on(canvasElement, "tap", ".delete", function (e) {
   var info = toolkit.getObjectInfo(this);
   var selection = toolkit.selectDescendants(info.obj, true);
   undoredo.transaction(function() {
       toolkit.remove(selection);
   });
});

Mostly the same, with the exception that the selection remove call is executed inside a transaction declared on the undo/redo manager. So anything that is removed as a part of the toolkit.remove(selection) call is automatically compounded into a single entry on the undo stack. Clicking the 'undo' button at this point will cause the node and all of its descendants to be restored, along with the edges between them.

Another example of compounding into a single transaction happens when the user adds a child to some node. The code used to be:

jsPlumb.on(canvasElement, "tap", ".add", function (e) {
    // this helper method can retrieve the associated
    // toolkit information from any DOM element.
    var info = toolkit.getObjectInfo(this);
    // get a random node.
    var n = jsPlumbToolkitDemoSupport.randomNode();
    // add the node to the toolkit
    var newNode = toolkit.addNode(n);
    // and add an edge for it from the current node.
    toolkit.addEdge({source: info.obj, target: newNode});
});

Now with the undo/redo manager it looks like this:

jsPlumb.on(canvasElement, "tap", ".add", function (e) {
    // this helper method can retrieve the associated
    // toolkit information from any DOM element.
    var info = toolkit.getObjectInfo(this);
    // get a random node.
    var n = jsPlumbToolkitDemoSupport.randomNode();

    undoredo.transaction(function() {
        // add the node to the toolkit
        var newNode = toolkit.addNode(n);
        // and add an edge for it from the current node.
        toolkit.addEdge({source: info.obj, target: newNode});
    });

});

.. the addition of the new node, and of its edge connecting it to its parent, is treated as a single entry on the undo stack.