This is a follow-up content as part of the series Juejin pushed me to learn Canvas, so I made a resume editor with Canvas, mainly introducing the design of data structure and implementation of the History
capability.
Related articles about the Canvas
resume editor project:
For an editor, History
, which includes undo
and redo
, is an essential capability. There are generally two methods to implement the history recording:
Store full snapshots, meaning that every operation requires storing the full data usually in JSON
format in an array. If the user triggers redo
, the full data is retrieved and applied to the Editor
object. The advantage of this implementation is simplicity with minimal design required, but the drawback is the potential memory explosion with extensive operations.
Implement based on Op
, where Op
represents an atomic record for an operation. For example, if shape A
is moved 3px
to the right, the Op
could be type: "MOVE", offset: [3, 0]
. To perform a rollback operation, the reverse operation like type: "MOVE", offset: [-3, 0]
is needed. This method offers finer granularity, reduces storage pressure, but demands complex design and calculation.
Since we are starting from scratch to design an editor, it's unlikely that we would choose solution 1
. We are more inclined to design atomic Op
structures to achieve History
. Therefore, when embarking on this path, we need to first design the data structure.
I highly recommend taking a look at the data structure design of quill-delta, which is an excellent design. It can describe a rich text document and handle complete insertions, deletions, and modifications through change
to the rich text. It also offers operations like compose
, invert
, diff
on data, and serves as an implementation of rich text Operational Transformation (OT
) algorithm. The design is truly remarkable.
Initially, I had no experience in designing data structures, let alone discussing the design of Op
to implement history recording. Therefore, while designing the data structure, I found myself pondering and struggling as achieving a data description level like quill-delta
was nearly impossible. Hence, I resorted to a simplistic design based on my own thoughts, which may still undergo further refinements in the future.
Given my lack of prior experience with Canvas
, my primary goal is learning. I want to keep all implementations as simple as possible. Therefore, I consider all elements to be rectangles since drawing rectangles is relatively straightforward. Thus, the base class of graphic elements should have x, y, width, height
attributes, along with a layer structure, which includes an additional z
. Moreover, for identifying graphics, each element requires an id
attribute.
As I aim to achieve a plugin-based implementation, where all shapes should inherit from this class, the custom function body needs to store its own data. Hence, I include an attrs
property, defined as Record<string, string>
, to store data. Since each graphic's drawing should be implemented by its subclass, an abstract method for drawing functions needs to be defined. This establishes the foundation of the data structure. We can further discuss the plugin-based design in the upcoming discussions.
So now that we have the basic data structure in place, let's think about what operations we should have. After some consideration, we concluded that there are five main operations: INSERT
, DELETE
, MOVE
, RESIZE
, and REVISE
. These five Op
operations cover all the actions we can perform on the current editor graphics. Therefore, all our subsequent design will revolve around these five operations.
It may sound simple at first glance, but designing it effectively is not an easy task. Since our goal is History
, we not only need to consider forward operations but also carefully design the invert
function to handle reverse operations. Taking the example of a previous MOVE
operation, when we move an element using MOVE(3, 0)
, the corresponding reverse operation should be automatically generated as MOVE(3, 0).invert = MOVE(-3, 0)
. What about RESIZE
operations, especially when resizing multiple elements simultaneously? We need to ensure that inverse operations can be implemented. One approach is to record the movement distances of each point, but this would lead to excessive information stored for each Op
. Similarly, for REVISE
, we need to include both the previous and new values of attributes in the Op
to execute it correctly.
So, how can we effectively address this challenge? It becomes evident that if we aim to use lightweight data to carry the content, storing unnecessary previous data that may not be used is not ideal. Hence, automatically extracting relevant content as invert-op
is a feasible solution. When performing the invert
operation, we can simply pass the Delta
before the operation as a parameter. By verifying it, the function signature would be Op.invert(Delta) = Op'
.
This looks promising, so we can now move forward with designing the complete set of Op
and Invert
methods. Initially, I intended to design the ability to combine several graphics for future expansion, hence I reserved a parentId
field for later development but it's not currently needed and can be disregarded. The Invert
operation essentially involves transforming operations case by case
: INSERT -> DELETE
, DELETE -> INSERT
, MOVE -> MOVE
, RESIZE -> RESIZE
, and REVISE -> REVISE
. The DeltaSet
here can be understood as all the current Delta
data, with a type signature similar to Record<string, Delta>
, maintaining a flat structure for easier data retrieval.
Now that we have designed the atomic operations and data structures based on Op
, we can move on to implementing the History
capability. Here, it's important to note that we had the idea of automatically generating InvertOp
based on DeltaSet
. There are two ways to achieve this.
The first approach is to generate an InvertOp
based on the current DeltaSet
before applying Op
, and then store this Op
in the History
module for undo operations.
The second approach is to first create a new Previous DeltaSet
, which is an immer
copy, before applying Op
. Then, both Prev DeltaSet
and Next DeltaSet
are passed to the History
module as an OnChangeEvent
for further operations.
In the end, I chose the second approach for implementation without any specific reasons. I just felt that the immer
copy might be used not only here but also as part of the event distribution of previous data values. So, the general implementation of applying Op
looks like this.
In fact, we can also see that the internal communication of the entire editor relies on the event
module. In other words, this apply
function does not directly call related content of History
. Our History
module independently mounts the CONTENT_CHANGE
event. Next, we need to design the data storage of the History
module. First, let's clarify what we want to achieve. The atomic Op
has been designed, so when designing the History
module, there is no need to save snapshots in full. However, it may not be ideal if each operation needs to be incorporated into the History Stack
. Typically, N
Op
are Undo/Redo
together. Therefore, this module should have a timer, a cache array, and a maximum time. If no new Op
is added within N
milliseconds, the Op
will be incorporated into the History Stack
. Additionally, there should be regular undo stack
and redo stack
, and the contents stored in the stack should not be very large, so the maximum storage capacity also needs to be set.
As mentioned earlier, we communicate through events, so we need to mount events here first, and prepare the Op
of Invert
and place it in the cache of batch operations.
Later, I thought about a problem - what if the user performs an Undo
operation within these N
milliseconds? After some thought, it's actually quite simple. At this point, we just need to clear the timer, immediately place the temporarily stored Op[]
in the Redo Stack
.
The actual redo
and undo
operations will be performed next. However, here the batch operation is done by looping through each Op
and applying them individually. This doesn't feel quite good since it requires multiple modifications. Although I will only perform batch rendering once later, the number of event triggers here seems a bit excessive. Additionally, there is one point to note: the operations performed in the History
module should not be recorded again in the History
itself, so there is a ApplyOptions
setting here that needs attention. Furthermore, after undo
, this part of the content needs to be inverted again and added to the redo stack
, and vice versa. At this point, we can directly take the DeltaSet
of the current editor.
In this article, we summarized the design of the data structures in our graphic editor and the implementation of the History
module. Although it does not involve the Canvas
itself for now, these are all basic capabilities of the editor and are also common skills that can be learned. There are still many other capabilities that we can introduce later, such as copy-paste module, canvas layering, event management, infinite canvas, on-demand drawing, performance optimization, focus control, reference lines, rich text, shortcuts, layer control, rendering order, event simulation, PDF
typesetting, etc. Overall, it's quite interesting. Feel free to follow me and stay tuned for upcoming articles.