DEV Community

Cover image for Exploring the Canvas Series: The Art of Time Reversal in the Canvas
Leo Song
Leo Song

Posted on

Exploring the Canvas Series: The Art of Time Reversal in the Canvas

Introduction

After more than a month of restructuring, I have successfully crafted a powerful multi-platform and entertaining drawing board. This drawing board integrates various creative brushes, providing users with a completely new painting experience. Whether on mobile or PC, users can enjoy a seamless interactive experience and impressive visual displays. Furthermore, this project incorporates many popular drawing board features found online, including but not limited to undo/redo, copy/delete, upload/download, multiple boards, and multiple layers. I won't list all the detailed functionalities here, but I look forward to your exploration.

Link: https://songlh.top/paint-board/

Github: https://github.com/LHRUN/paint-board

preview

After completing the refactoring, I plan to write a series of articles. On one hand, it is to document technical details, a habit I have maintained over time. On the other hand, it is to promote the project, hoping to gain your usage and feedback. Of course, giving it a "Star" would be the greatest support for me.

This article is the first installment of the "Exploring the Drawing Board" series, where I will delve into the details of the undo and redo functionality. Examples will be provided using Fabric.js syntax, but the concepts are applicable across various frameworks.

Solution One: Canvas-level Caching

The first approach is canvas-level caching, which is the simplest. In this approach, there is no need to be concerned about the specific modifications made to elements, and there is no need to differentially process the entire canvas data. Simply put, whenever an effect needs to be changed (such as adding, deleting, or modifying elements), just push the current canvas data push the history operation stack, and then undo and redo by reloading the corresponding data.

The advantage of this approach lies in its simplicity and straightforwardness; there is no need to consider details, as the history operation stack records every step of the changes. However, it is important to note that due to the indiscriminate caching of the entire canvas data, memory usage can be relatively high.

There are generally two popular approaches to maintaining this kind of history stack:

  1. Single operation stack:
  • Utilize a single operation stack to record each step of the operations.
  • Use an index to specify the current state, facilitating undo and redo operations.
  • When a new operation is performed, push it onto the stack and update the index of the current state.
  1. Dual-stack maintenance:
  • Maintain two stacks, one for undo operations and the other for redo operations.
  • When a user performs a new operation, push it onto the undo stack and clear the redo stack.
  • During an undo operation, pop the latest state from the undo stack and save it to the redo stack.
  • During a redo operation, pop the state from the redo stack and push it onto the undo stack.

Here is a simple example of a single operation stack:

class History {
  constructor() {
    this.stack = []; // An array used to hold the history state
    this.currentIndex = -1; // A subscript pointing to the current state
  }

  // Adding the current state to the history
  saveState(state) {
    // If there are currently states that have not been redone, remove all states after the current subscript.
    if (this.currentIndex < this.stack.length - 1) {
      this.stack = this.stack.slice(0, this.currentIndex + 1);
    }
    this.stack.push(state);
    this.currentIndex++;
  }

  // Undo, return to previous state
  undo() {
    if (this.currentIndex > 0) {
      this.currentIndex--;
      return this.stack[this.currentIndex];
    }
    return null;
  }

  // Redo, return to next state
  redo() {
    if (this.currentIndex < this.stack.length - 1) {
      this.currentIndex++;
      return this.stack[this.currentIndex];
    }
    return null;
  }

  canUndo() {
    return this.currentIndex > 0;
  }

  canRedo() {
    return this.currentIndex < this.stack.length - 1;
  }

  clear() {
    this.stack = [];
    this.currentIndex = -1;
  }
}
Enter fullscreen mode Exit fullscreen mode

Solution Two: Cache Current Operation Commands

When employing canvas-level caching, the large memory footprint and the presence of significant redundant data lead to the second approach – caching only the current operation commands. This strategy is more flexible and memory-efficient, as it records each specific operation step, avoiding the redundancy of storing the entire canvas state. In complex projects, judicious use of this approach can result in significant performance improvements.

For example:

  • When adding an element, store an "add" command along with the currently drawn object. To undo, simply delete the current element.
  • When deleting an element, store a "delete" command and the identifier of the deleted element. To undo, restore the deleted element.
  • When modifying an element, store a "modify" command and the current changed element data. For simple operations like position movement, only store changes in position coordinates. For complex operations like scaling, store changes in current scaling ratios and other relevant data. To undo, perform the corresponding operation based on the command type.

This approach not only reduces memory usage in complex projects but also enhances flexibility by controlling each operation step effectively. However, the implementation process can be complex, requiring significant effort in the diff algorithm to accurately track state changes and ensure precise data recording.

Here is a simple example, with a strong association with the drawing logic in a specific project:

class Command {
  constructor(execute, undo, value) {
    this.execute = execute; // execute command
    this.undo = undo;       // undo command
    this.value = value;     // The parameter of the command, which can be the current drawing object
  }

  exec() {
    this.execute(this.value);
  }

  unexec() {
    this.undo(this.value);
  }
}

class History {
  constructor() {
    this.commands = []; // Save command stack
    this.index = -1;    // Pointer to the command's location on the stack
  }

  // Execute a new command and put it on the stack
  execute(command) {
    this.commands.slice(0, this.index)
    this.commands.push(command);
    command.exec();
    this.index++;
  }

  undo() {
    if (this.index < 0) return;
    const command = this.commands[this.index];
    if (command) {
      command.unexec();
      this.index--;
    }
  }

  redo() {
    const command = this.commands[this.index + 1];
    if (command) {
      command.exec();
      this.index++;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Canvas-level Caching Optimization

However, when dealing with complex drawing effects, the calculation of command-level caching can become intricate, making it challenging to accurately compute differences between multiple elements. In such situations, I recommend adopting an optimization strategy by refining the canvas-level caching from the first approach. The primary drawback of canvas-level caching is its significant memory consumption. However, by performing a diff operation on the canvas cache state after each operation and storing only the current differential data, the memory footprint can be significantly reduced.

For the diff operation, I recommend using jsondiffpatch

jsondiffpatch provides several commonly used APIs:

  • jsondiffpatch.diff(left, right[, delta])
    • Compares the differences between two objects, left and right. The optional parameter delta is a known difference, enhancing performance.
  • jsondiffpatch.patch(obj, delta)
    • Applies the given difference delta to the object obj, returning the updated object.
  • jsondiffpatch.unpatch(obj, delta)
    • Removes the specified difference delta from the object obj`, returning the object to its pre-patched state.

Below is an example of applying jsondiffpatch to a History class, which is the approach I am currently using in my drawing board project.

`ts
class History {
diffs: Array = []
canvasData: Partial = {}
index = 0

constructor() {
const canvasJson = canvas.toDatalessJSON()
this.canvasData = canvasJson
}

saveState() {
this.diffs = this.diffs.slice(0, this.index)
const canvasJson = canvas.toDatalessJSON()
const delta = diff(canvasJson, this.canvasData)
this.diffs.push(delta)
this.index++
this.canvasData = canvasJson
}

undo() {
if (canvas && this.index > 0) {
const delta = this.diffs[this.index - 1]
this.index--
const canvasJson = patch(this.canvasData, delta)
canvas.loadFromJSON(canvasJson, () => {
canvas.requestRenderAll()
this.canvasData = canvasJson
})
}
}

redo() {
if (this.index < this.diffs.length && canvas) {
const delta = this.diffs[this.index]
this.index++
const canvasJson = unpatch(this.canvasData, delta)
canvas.loadFromJSON(canvasJson, () => {
canvas.requestRenderAll()
this.canvasData = canvasJson
})
}
}

clean() {
canvas.clear()
this.index = 0
this.diffs = []
this.canvasData = {}
}
}
`

Conclusion

Thank you for reading. This is the whole content of this article, I hope this article is helpful to you, welcome to like and favorite. If you have any questions, please feel free to discuss in the comment section!

Top comments (0)