*This article is a summary of the talk The Architecture of Web-Based Graphic Editors and 7 Design Patterns presented at FEConf 2023. The content of the presentation will be published in a two-part series. Part 1 covered the basic architecture of a web-based graphic editor and the design patterns embedded within it. Part 2 will take a deeper dive into design patterns by actually implementing a graphic editor and addressing its problems. All images in this article are from the presentation slides of the same name and are not individually cited. The presentation slides can be downloaded from the FEConf 2023 website.
'The Architecture of Web-Based Graphic Editors and 7 Design Patterns' / Heungwoon Shim, Frontend Engineer at Naver, presented at FEConf 2023
Implementing a Graphic Editor and Applying Design Patterns
In this section, we'll explore how to apply five design patterns by implementing a sample graphic editor. I recommend focusing on the overall flow and concepts rather than the specific implementation details.
1. Creating Controllers (Parts) from a Saved Model
Let's assume the sample graphic editor we're building supports a selection tool, a rectangle tool, and a pen tool. The domain model for this editor can be represented by the following JSON, consisting of rectangles, circles, and paths.
The domain model of the graphic editor
Now, let's look at our first goal: "Creating controllers from the model." As I mentioned earlier, the internal structure is implemented as shown in the diagram below. The highlighted parts are the structures involved in creation. The graphic editor reads the model and displays it in the graphic viewer. So when the model is passed to the graphic editor, it appears in the graphic viewer.
Creating controllers (parts) from the model
This process can be expressed in pseudocode as follows. The code on the left shows the graphic editor telling the graphic viewer to create parts, passing the model as a parameter. The graphic viewer then calls createParts
, receives the model as a parameter, iterates through the model array, and creates a specific part for each item based on its type. After creating the parts, it renders them and displays them on the screen using addPart
.
Pseudocode for creating a controller from a model
But doesn't the part-creation logic seem a bit off? The highlighted code below needs to be modified every time a new shape is added. We previously said that we can use design patterns to isolate the parts that change. Here, we'll use a Simple Factory to isolate this changing part.
Inefficient code for creating parts
The Factory Trio
Before we look at the solution, let's briefly review factories. There are three types of factories:
- Simple Factory: A pattern that centralizes object creation in one class.
- Factory Method Pattern: A pattern where a superclass provides an interface for creating objects, but lets subclasses decide which class to instantiate.
- Abstract Factory Pattern: A pattern that implements an interface and creates a family of products using composition.
Since a Simple Factory is sufficient to solve this example, we'll use that.
Solution - Isolating the Changing Part
We move the if-else
conditional statement from the highlighted code into a PartFactory
. Because the PartFactory
is now separate, the GraphicViewer
that calls it no longer needs to know about the specific part types. Therefore, the createParts
function can simply iterate through the model and call the PartFactory
to create the parts. This means that if new types are added or changed, the GraphicViewer
doesn't need to be modified; only the PartFactory
needs to be updated.
Isolating the changing part
This method offers two main advantages:
- Open-Closed Principle (OCP)
- Dependency Inversion Principle (DIP)
By using a Simple Factory, we adhere to the OCP, being open for extension but closed for modification. We also adhere to the DIP by delegating creation responsibility to the PartFactory
and depending on the Part
abstraction.
2. Creating Views (Figures) from a Controller
Now let's look at creating views. The model we looked at earlier was a one-dimensional model. However, we don't just edit one-dimensional models; we also need to handle nested structures, layers, and groups. How can we represent these nested structures? A nested model would look like the diagram below.
Nested model
There's a pattern that makes it easy to handle nested models: the Composite pattern. The Composite pattern allows clients to treat individual objects and composite objects uniformly.
Composite pattern
The concept of the Composite pattern originates from the idea of a part-whole hierarchy. It treats a tree of nodes as if it were a single object. In other words, the gray group area in the diagram below is treated the same as the light blue node object.
Part-whole hierarchy
This is why this pattern is often used when implementing graphical user interfaces, and you can see that it's very similar to React's rendering process.
Using this pattern, you can treat individual views and nested views as the same type for rendering. In the code on the left, the addChild
function takes a part as a parameter, which can be either a single object or a composite object. Either type can be added as a child. Next, in the render()
code, the part's render method is called when the model is first loaded or changed. It renders itself and then iterates through its children to render them.
However, there's one problem here. For components that cannot have children, like a text part, you need code to throw an error when addChild
is called, as shown in the code on the right.
Composite pattern
To summarize, this pattern allows you to recursively apply operations like rendering to an entire structure with simple code.
Features of the Composite Pattern
The previous example violated the SRP (Single Responsibility Principle). That's why the Composite pattern is known for trading SRP for transparency. Here, transparency means treating individual and composite objects as the same type. This reduces type safety. As seen with the text part in the previous example, logically inconsistent operations can occur, and you must prevent them. Therefore, this pattern is said to require a balance between transparency and safety during design.
Features of the Composite pattern
3. Modifying the Model Using Tools
Next, let's look at modifying the model using tools. The structures related to the editing process include the Event Dispatcher, Command Stack, Root Part, Tool, Request, and EditPolicy, as shown in the diagram below.
Structure related to editing
As mentioned before, an editing task is "the process of converting an event into a state change." There is a special component in this process: the "Tool". If we look at this process in detail with the structure introduced earlier, it looks like the diagram below.
Internal structure of the event flow
The Event Dispatcher is responsible for converting low-level browser events into high-level editor events. The Event Dispatcher receives an event through a masking layer, processes it, and passes it to the canvas.
Features of a Tool
The tool described earlier has a distinct feature: even with the same event, the result must differ based on which tool is selected. For example, if the rectangle tool is selected, dragging should draw a rectangle. If the pen tool is selected, it should draw with a pen. In other words, the graphic editor can have only one tool active at a time.
Implementing a Tool
The code on the left is for the Event Dispatcher. The event listening part is represented in pseudocode. For example, it receives a mousemove
event and calls transmitMouseMove
. transmitMouseMove
then passes the event to the viewer. In the viewer code on the right, it receives the event and draws the appropriate element—a rectangle, circle, or pen stroke—on the screen based on the currently active tool.
Tool implementation
There's a problem with this code as well. The if-else
statement on the right has to grow every time a new tool is added. This means the GraphicViewer
code is not closed for modification. Also, similar code is repeated.
Solution - The State Pattern
The way to solve this problem is with the State pattern. In the code on the right, the if-else
statement is removed, and the event is passed directly to the tool in the receiveEvent
method. The Tool
becomes an object that represents the current state, with an abstract base class delegating state-specific handling to concrete subclasses. In other words, representing the current state as a single class is the essence of the State pattern. This allows you to isolate the behavior for each state into its own class, making code management easier and eliminating repetitive conditional logic.
With the State pattern, the GraphicViewer
is now closed for modification but open for extension, allowing us to adhere to the OCP.
State pattern
To summarize, the rectangle, circle, and pen tools from the previous example are now implemented as concrete classes that handle their specific states. This way, you only need to inherit from a base Tool
class to implement the functionality for a new tool.
4. Completing the MVC Structure
Now let's revisit the event flow so far and complete the MVC structure. We just learned about tools, but is it desirable for the tool to modify the model directly, as in the previous example? In an MVC structure, who should know how to modify the model?
For example, let's say we have an eraser tool that erases a bitmap. Since this eraser is only for bitmaps, it should be able to erase the bitmap image on the left but not the vector image on the right.
In this situation, if the eraser tool modifies the model directly, it needs to know about both erasable and non-erasable parts. Then, every time a new part is added, it needs to know whether that element is erasable or not. This means the code must be constantly modified as new elements are added, violating the Open-Closed Principle for the eraser tool.
To solve this, the part itself should know how to interpret the event. When the eraser requests to delete an element, the part receives this event and handles it.
Event handling for the eraser tool
Let's think about this more fundamentally. If we use MVC, who modifies the model? The Controller. In our example, the Part
can be considered the controller. Therefore, the Part
must know how to interpret events and be able to modify the model. And the tool doesn't pass the event directly to the Part
; instead, it processes the event into a Request
object and asks the Part
to modify the model. This allows various editing requests to be encapsulated in a Request
object. Here, the Request
can be considered a high-level event.
So, should we put the model modification logic directly inside the Part
? That's also problematic. Because if the editing logic is similar, duplicate logic could occur across different Part
s. This would once again violate the Open-Closed Principle for the Part
.
The problem with parts knowing the modification logic
Solution - The EditPolicy Pattern
A smart way to solve this problem is with the EditPolicy
pattern. An EditPolicy
is a micro-controller created by separating the model modification logic from the Part
, making this logic reusable.
EditPolicy
The code below represents the drag behavior of a tool. When a mousedown
event occurs, dragging starts, and when a mouseup
event occurs, dragEnd
is called. In dragEnd
, the changeModel
function is called to change the model. And the changeModel
method of the ResizeTool
performs a specific task, as shown below.
EditPolicy Example - Resize Tool
Now let's separate the Policy from the ResizeTool
. We move the model modification code from ResizeTool
to ResizePolicy
. This allows the changeModel
of ResizeTool
to be abstracted. And we add an installPolicy
function to the Part
, allowing it to manage multiple policies. In other words, the policies are stored in an array, allowing the Part
to apply various controller logics.
Extracting the EditPolicy
This means the ResizeTool
only needs to send the request, and the changeModel
logic that was in ResizeTool
is moved to its superclass, Tool
.
Delegating to the superclass
The Strategy Pattern
The EditPolicy
we just looked at is an implementation of the Strategy pattern. This pattern works by separating the changing algorithm (the model modification logic) from the context (the Tool
and Part
) and delegating the task to a Policy
object. The Part
can choose a Policy
to change its behavior dynamically, even at runtime. It also makes algorithms easy to reuse.
Features of the Strategy pattern
5. Managing Work History
This section is related to undo and redo, which are used to manage the work history. In the event flow we've built so far, an event passes through the Event Dispatcher, Graphic Viewer, Tool, and Part, eventually causing the EditPolicy
to modify the model.
Command creation by EditPolicy
If we improve the EditPolicy
process one more time, instead of the EditPolicy
modifying the model directly, it creates an object called a Command
. This command is pushed onto the Command Stack
, which in turn executes it to change the model. Inside the Command Stack
, Command
objects that encapsulate model modifications are stacked one by one, enabling operations like undo and redo.
Command Stack
A summary of the final flow is shown in the diagram below. The tool receives an event, requests the Part
's EditPolicy
to create a command, and this command then modifies the model. When the model is modified, the Part
requests a refresh, and the view is updated.
The final MVC flow
Conclusion
So far, we've explored seven design patterns through the architecture of a web-based graphic editor. We also explored how to apply design patterns by improving sample implementation code.
What I've shared today is just one example of how design patterns can be applied. I hope this experience will be valuable as you build your own graphic editors and helpful for applying design patterns in your work. I encourage you to discover better patterns through your own experiences, share them with others, and collaborate on their application. Thank you.
Top comments (0)