Previously, we discussed the design of data structures and data manipulation within the clipboard, which leaned more towards data-centric operations. Now, let's shift our focus to basic graphic rendering and graphic state management, or in other words, we need to implement a lightweight DOM
.
Related articles on the Canvas
resume editor project:
When working on a project, we must start from the requirements. First, it is essential to clarify that we are developing a resume editor, which only requires a limited variety of graphic types: rectangles, images, and rich text shapes. Thus, we can conveniently abstract this: we can assume that any element is a rectangle to achieve our goal.
Since rendering rectangles is quite straightforward, we can abstract this part of the graphics directly from the data structure. The base class for graphic elements includes the definitive properties x, y, width,
and height
, and with the addition of a hierarchical structure, we incorporate a z
value. Furthermore, because we need to identify the graphics, we also assign an id
.
Our graphics will undoubtedly have numerous properties. For instance, a rectangle exists with background, border size, and color, while rich text will require attributes to render specific content. Therefore, we need an object to store this content, and since our implementation is plugin-based, the actual graphic rendering should be handled by the plugins themselves. This part needs to be implemented by subclasses.
As for the rendering process, we consider dividing it into two layers. The inner Canvas
is meant for drawing the specific graphics, where we anticipate implementing incremental updates. Conversely, the outer Canvas
is responsible for rendering intermediate states, such as selected graphics, multiple selections, and adjusting graphic positions/sizes; this will refresh entirely, and we may also draw a ruler in this layer later on.
A crucial point to note here is that, since our Canvas
doesn't use vector graphics, if we directly set the width x height
of the editor on a 1080P
display, there won't be any issues. However, if we encounter a 2K
or 4K
display, blurriness can occur. Thus, we need to obtain the devicePixelRatio
, which is the ratio of physical pixels to device-independent pixels. We can acquire this value from window
to control the size
attributes of the Canvas
element.
At this point, we also need to handle the resize
issue. We can use resize-observer-polyfill
to implement this part of the functionality, but it's essential to ensure that our width
and height
are integers; otherwise, the graphics in the editor may become blurry.
In reality, when we are implementing a complete graphic editor, it may not be just about complete rectangular nodes. For instance, when drawing irregular shapes like clouds, we need to place the relevant node coordinates in attrs
and complete the calculation of Bezier
curves during the actual drawing process. However, we also need to address a crucial question: how to determine whether a clicked point is inside or outside the shape? If it’s inside the shape, the node should be selected upon clicking, while if it's outside, no selection should occur. Since we are dealing with closed shapes, we can employ the ray casting method to achieve this. We send a ray from the point in one direction; if the number of nodes it crosses is odd, it indicates that the point is inside the shape, whereas an even number signifies that the point is outside the shape.
Simply drawing the shapes is not enough; we must also implement relevant interaction capabilities. During the implementation of these interactions, I encountered a somewhat tricky issue—since there is no DOM
, every operation must be calculated based on positional information. For example, when resizing a shape, the resize points need to be in a selected state, and the click must precisely align with those points with a specific offset. Subsequently, we adjust the shape's size based on MouseMove
events. In fact, there are numerous interactions to consider here, including multi-selection, drag selection, and Hover
effects—all of which are executed through the three events: MouseDown
, MouseMove
, and MouseUp
. Therefore, managing state and rendering UI
interactions becomes a complex challenge. Here, I can only think of carrying different Payloads
based on the various states to facilitate the drawing of interactions.
When implementing interactions, I pondered for a long time over how to best achieve this capability. As mentioned earlier, there is no DOM
here, so initially, I implemented a rather chaotic state management system using MouseDown
, MouseMove
, and MouseUp
. This approach was entirely event-driven, executing related side effects to invoke methods for redrawing the Mask Canvas
layer.
Later, I felt that maintaining this kind of code was practically impossible, so I decided to make some changes. I stored all the necessary states in a single Store
and managed event notifications for state changes through my custom event management. This way, I could strictly control what would be drawn based on the type of state change, effectively abstracting a layer of related logic. However, it does mean that I now maintain a large number of interrelated states, resulting in many if/else
statements to handle different types of state changes. Although the state management has improved somewhat compared to before—allowing me to know exactly where state changes originate from—it still remains challenging to maintain.
Ultimately, I pondered whether the DOM
we manipulate in the browser truly exists, or whether the windows we manage on a PC
are really there. The answer is definitely no. Although we can easily perform various operations through the APIs provided by the system or the browser, what we see is actually drawn by the system—essentially graphics. Events, states, collision detection, and so on are all simulated by the system, and our Canvas
possesses similar graphical programming capabilities.
Therefore, we can certainly implement DOM
-like capabilities here, as what I want to achieve seems to fundamentally involve the connection between DOM
and events. The DOM
structure is a well-established design, with some excellent design features such as the event flow. This allows us to ensure that events originate from the ROOT
node and ultimately end at the same point, rather than flattening out each Node
's events. Moreover, the entire tree structure and its state are created through user interaction with the DOM
API, meaning we only need to manage the ROOT
. This approach makes things much easier, and the next phase of state management will be prepared using this method; hence, we will first implement a base class for Node
.
Next, we just need to define the Body
element similar to HTML
, here we will set it as the Root
node, which inherits from the Node
class. In this setup, we take control of the entire editor's event dispatching; events inherited from this point can be dispatched to child nodes. For instance, our click events can simply set up the MouseDown
event handler in child nodes. Furthermore, we need to design the capability for event dispatching; we can also implement event capturing and bubbling mechanisms, allowing us to easily handle event triggers using a stack.
Now, all we need to do is define the relevant node types, and by distinguishing between different types, we can implement various functions. For example, use the ElementNode
for drawing shapes, ResizeNode
for resizing nodes, and FrameNode
for selecting content. Let's take a look at the ElementNode
, which represents the actual node.
Here, we discussed how to abstract basic shape drawing and state management. Our requirements lead to a relatively simple design for the drawing capabilities, while the state management underwent three iterations before we settled on a lightweight DOM
approach. Next, we will need to discuss how to design capabilities for hierarchical rendering and event management.