DEV Community

Cover image for Drawing with FabricJS and TypeScript Part 7: Undo/Redo
Matthew Jones
Matthew Jones

Posted on • Originally published at exceptionnotfound.net

Drawing with FabricJS and TypeScript Part 7: Undo/Redo

In this part, we're going to further improve our drawing canvas by adding an undo/redo functionality. To accomplish this, we have to implement a state manager system which keeps track of the various states of the canvas and the objects on it. It's not as bad as it sounds, I promise. Let's go!

Alt Text
And our manager will be WAY less hipster-y. Photo by Icons8 Team / Unsplash

The Sample Project

As I'm sure you're aware by now, there's a sample project that goes along with this series of posts. Check it out!

The State Manager

In order to accomplish this undo/redo functionality, we need a class that will keep track of the various states of the drawing canvas. Said class will need to keep a representation of the canvas in JSON, so that said representation can be easily restored.

Lucky for us, FabricJS already provides a way to get the JSON for the canvas: the method toDatalessJSON(). By using this method, we can get the complete state of the canvas at any given time.

The state manager itself will need to keep a stack of states, so that we can pop off the top state to undo. It will also need to keep a separate stack of popped states, so that we can redo.

Alt Text
Photo by Iva Rajović / Unsplash

Let's see the annotated code for our StateManager class:

class StateManager {
    private currentState: string; 
    private stateStack: string[]; //Undo stack
    private redoStack: string[]; //Redo stack
    private locked: boolean; //Determines if the state can currently be saved.
    private maxCount: number = 100; //We keep 100 items in the stacks at any time.

    constructor(readonly canvas: fabric.Canvas) {
        this.currentState = canvas.toDatalessJSON();
        this.locked = false;
        this.redoStack = [];
        this.stateStack = [];
    }

    saveState() {
        if (!this.locked) {
            if (this.stateStack.length === this.maxCount) {
                //Drop the oldest element
                this.stateStack.shift();
            }

            //Add the current state
            this.stateStack.push(
                this.currentState
            );

            //Make the state of the canvas the current state
            this.currentState = this.canvas.toDatalessJSON()

            //Reset the redo stack.
            //We can only redo things that were just undone.
            this.redoStack.length = 0;
        }
    }

    //Pop the most recent state. Use the specified callback method.
    undo(callback?: Function) {
        if (this.stateStack.length > 0)
            this.applyState(this.redoStack, this.stateStack.pop(), callback);
    }

    //Pop the most recent redo state. Use the specified callback method.
    redo(callback?: Function) {
        if (this.redoStack.length > 0) 
            this.applyState(this.stateStack, this.redoStack.pop(), callback);
    }

    //Root function for undo and redo; operates on the passed-in stack
    private applyState(stack: string[], newState, callBack?: Function) {
        //Push the current state
        stack.push(this.currentState);

        //Make the new state the current state
        this.currentState = newState;

        //Lock the stacks for the incoming change
        const thisStateManager = this;
        this.locked = true;

        //Update canvas with the new current state
        this.canvas.loadFromJSON(this.currentState, function () {
            if (callBack !== undefined)
                callBack();

            //Unlock the stacks
            thisStateManager.locked = false;
        });       
    }
}

Now our question is, how do we use this class?

Modifications to DrawingEditor

We must make a few modifications to the root DrawingEditor class.

First, we need some shortcut methods to allow the DrawingEditor to undo, redo, and save the current state:

class DrawingEditor {
    stateManager: StateManager;
    //...Other properties

    constructor(private readonly selector: string,
        canvasHeight: number, canvasWidth: number) {
        //...Initialize canvas

        this.stateManager = new StateManager(this.canvas);

        //...
    }

    //...Other methods

    undo(): void {
        this.stateManager.undo();
    }

    redo(): void {
        this.stateManager.redo();
    }

    private saveState() {
        this.stateManager.saveState();
        this.canvas.renderAll();
    }
}

The method saveState() is used as a shortcut method to allow other methods to a) save the current state and b) render all objects on the canvas again. saveState() needs to be used in quite a few places, most notably whenever a canvas object is modified, created, or deleted.

class DrawingEditor {
    //...Properties and constructor

    private initializeCanvasEvents() {
        //...Other events

        this.canvas.on('mouse:up', (o) => {
            this.isDown = false;
            switch (this.cursorMode) {
                //If the cursor mode is currently Draw when a mouseup
                //event occurs, we have just finished dragging to
                //create that object. Hence, we need to add the new
                //state of the canvas to the state manager.
                case CursorMode.Draw:
                    this.isObjectSelected = false;
                    this.saveState();
            }
        });

        //If an object has been modified at all, save the new state
        this.canvas.on("object:modified", (e) => {
            this.saveState();
        });
    }

    //This method is called by the DeleteComponent from Part 5.
    deleteSelected(): void {
        this.canvas.remove(this.canvas.getActiveObject());
        this.canvas.renderAll();

        this.saveState();
    }

    //...Other methods
}

Our DrawingEditor will now save the state of the canvas whenever objects are changed, created, or deleted. But we still need toolbar items for undo/redo. Guess what that means? We need some new display components!

Components for Undo/Redo

First up is the UndoComponent:

class UndoComponent extends ControlComponent {

    constructor(target: string, parent: DrawingEditor) {
        super(
            target, //Selector
            "fa fa-undo", //Icon CSS Classes
            "Undo", //Tooltip
            parent,
            {
                'click': () => { parent.undo(); }
            });
    }

    render() {
        const html = `<button id="${this.target.replace('#', '')}" title="${this.hoverText}" class="btn btn-info">
                        <i class="${this.cssClass}"></i>
                     </button>`;

        $(this.target).replaceWith(html);
    }
}

Nothing too complex here, I think. We also need the RedoComponent:

class RedoComponent extends ControlComponent {

    constructor(target: string, parent: DrawingEditor) {
        super(
            target, //Selector
            "fa fa-repeat", //Icon CSS Classes
            "Redo", //Tooltip
            parent,
            {
                'click': () => { parent.redo(); }
            });
    }

    render() {
        const html = `<button id="${this.target.replace('#', '')}" title="${this.hoverText}" class="btn btn-info">
                        <i class="${this.cssClass}"></i>
                     </button>`;

        $(this.target).replaceWith(html);
    }
}

Just like the other components, we need to modify the DrawingEditor and the Razor Page markup and script:

class DrawingEditor {
    //...Properties and constructor
    //...Other methods

    addComponent(target: string, component: string) {
        switch (component) {
            //...Other cases
            case 'undo':
                this.components[component] 
                  = [new UndoComponent(target, this)];
                break;
            case 'redo':
                this.components[component] 
                  = [new RedoComponent(target, this)];
                break;
        }
    }
}
<div class="row drawingToolbar bg-secondary text-light">
    <div class="btn-toolbar">
        <div class="btn-group mr-2" role="group">
            <div id="undoComponent"></div>
            <div id="redoComponent"></div>
            <div id="deleteComponent"></div>
        </div>
    </div>
    <!--Shapes toolbar-->
    <!--Color, style, and thickness toolbar-->
</div>
var editor;
$(function () {
    //Instantiate canvas
    const components = [
        //Other components
        { id: '#undoComponent', type: 'undo' },
        { id: '#redoComponent', type: 'redo' }
    ];

    //...Rest of script
});

GIF Time!

Now it's time to see what we've got! Here's a GIF of the undo/redo functionality in action:

That works pretty well, I'd say! Now we have functioning undo/redo calls in our FabricJS canvas!

Summary

In order to implement undo/redo, we needed to do the following:

  1. Create a StateManager class which could store stacks of canvas state for both undo and redo.
  2. Create undo and redo components that the user could click on.

Don't forget to check out the sample project over on GitHub!

In the next part of this series, we'll implement our own cut/copy/paste functionality, as well as hotkeys! Stay tuned for the penultimate part of Drawing with FabricJS and TypeScript!

Happy Drawing!

Discussion (0)