Fabric JS - UNDO & REDO optimization using JSON diff

Viewed 282

Currently, I have implemented quite standard UNDO and REDO by using listeners to trigger canvas.getObjects() whose JSON output I store in a stack.

// Canvas modified listeners
canvas?.on('object:modified', onCanvasModifiedHandler)
canvas?.on('object:removed', onCanvasModifiedHandler)
canvas?.on('object:changed', onCanvasModifiedHandler)

When the user clicks undo and redo, we fetch JSON representation of the canvas from the stack and loads it using canvas?.loadFromJSON(json, () => { ... })

My problem is that it is quite inefficient to store the entire JSON representation of the canvas when the actual change is quite small. As a result, this approach causes my application to freeze for 500 milliseconds when the user clicks UNDO and REDO.

My proposed solution is to store only the JSON diff by using for example this package, although it is quite an undertaking. https://www.npmjs.com/package/jsondiffpatch

My question is if anyone has had this problem before, and how did you solve it in that case? Or if someone has any other ideas.

Inspired by this thread: https://bountify.co/undo-redo-with-2-canvases-in-fabric-js

1 Answers

I think you need to use the command pattern for this. It will be more efficient than using all JSON data. For that, you need to implement the next approach:

  1. Create a class for storing History. It maybe looks like this
class CommandHistory {
  commands = [];
  index = 0;
  getIndex() {
    return this.index;
  }
  back() {
    if (this.index > 0) {
      let command = this.commands[--this.index];
      command.undo();
    }
    return this;
  }
  forward() {
    if (this.index < this.commands.length) {
      let command = this.commands[this.index++];
      command.execute();
    }
    return this;
  }
  add(command) {
    if (this.commands.length) {
      this.commands.splice(this.index, this.commands.length - this.index);
    }
    this.commands.push(command);
    this.index++;
    return this;
  }
  clear() {
    this.commands.length = 0;
    this.index = 0;
    return this;
  }
}

// use when you init your Canvas, like this.history = new CommandHistory();
  1. Then you must implement the command classes for your commands.

For adding object

class AddCommand {
  constructor(receiver, controller) {
    this.receiver = receiver;
    this.controller = controller;
  }
  execute() {
    this.controller.addObject(this.receiver);
  }
  undo() {
    this.controller.removeObject(this.receiver);
  }
}

// When you will add object on your canvas invoke also this.history.add(new AddCommand(object, controller))

For removing object

class RemoveCommand {
  constructor(receiver, controller) {
    this.receiver = receiver;
    this.controller = controller;
  }
  execute() {
    this.controller.removeObject(this.receiver);
  }
  undo() {
    this.controller.addObject(this.receiver);
  }
}

The fabric.js has the saveState method for every object http://fabricjs.com/docs/fabric.Object.html#saveState. And you can use it for implementing the transform command, which will be added to the history object when you will modify your fabric object on the canvas.

class TransformCommand {
  constructor(receiver, options = {}) {
    this.receiver = receiver;
    this._initStateProperties(options);

    this.state = {};
    this.prevState = {};

    this._saveState();
    this._savePrevState();
  }
  execute() {
    this._restoreState();
    this.receiver.setCoords();
  }
  undo() {
    this._restorePrevState();
    this.receiver.setCoords();
  }
  // private
  _initStateProperties(options) {
    this.stateProperties = this.receiver.stateProperties;
    if (options.stateProperties && options.stateProperties.length) {
      this.stateProperties.push(...options.stateProperties);
    }
  }
  _restoreState() {
    this._restore(this.state);
  }
  _restorePrevState() {
    this._restore(this.prevState);
  }
  _restore(state) {
    this.stateProperties.forEach((prop) => {
      this.receiver.set(prop, state[prop]);
    });
  }
  _saveState() {
    this.stateProperties.forEach((prop) => {
      this.state[prop] = this.receiver.get(prop);
    });
  }
  _savePrevState() {
    if (this.receiver._stateProperties) {
      this.stateProperties.forEach((prop) => {
        this.prevState[prop] = this.receiver._stateProperties[prop];
      });
    }
  }
}

Now you can add your commands to your history and execute or undo them.

Related