A highly customizable rich-text editor for Flutter
We have been looking for a rich-text editor that meets our needs. To date, we still haven't found a solution, so we decided to design and develop the new AppFlowy Editor component ourselves.
By Lucas@AppFlowy
This article describes the technical design of the AppFlowy Editor.
AppFlowy is an open-source alternative to Notion built with Rust and Flutter.
An editor is a core component in AppFlowy. In many scenarios, such as Document, Grid, and Board, the editor component we used till v0.0.5 is unable to support certain business requirements.
We have therefore been seeking an editor that will support all of AppFlowy's use cases and we have concluded that we need to design and develop our own editor for AppFlowy which we will call the AppFlowy Editor.
Before diving into our own editor, we would like to give a special thanks to flutter_quill, its author, and the community. Without you, we wouldn't have made it this far.
Issues with the Editor Component
As mentioned earlier, in the early versions (v0.0.1 to v0.0.5) of AppFlowy, we used flutter_quill as our editor component.
In the process of using this library, we have encountered problems with extensibility, consistency, and code coverage.
Although these problems may be addressed in future versions of flutter_quill, we do not wish to rely on updates to this component in order to move forward with our development.
Issues with Extensibility
We have encountered difficulty with quickly extending new components (aka plug-ins) and shortcuts.
When it comes to components, an example issue is our requirement to insert Grid and Board into existing documents. We have defined a data structure for our new AppFlowy Editor that simplifies this process. We only need to define a new node with a new type and define a corresponding
NodeWidgetBuilder
to render these components in the AppFlowy Editor.We also need additional shortcuts extensibility, such as markdown syntax support and shortcuts for key combinations (
meta + shift + option + key
). The new AppFlowy Editor supports customizing more shortcuts.Issues with Self-Consistent Production Process
We have been unable to support self-consistent context production processes, such as inserting new components via the slash command or a floating toolbar.
The new AppFlowy Editor supports customizing toolbar items and slash menus.
Issues with Code Coverage and Stability
The previous editor component lacked stability and sufficient code coverage.
To date, the code coverage of the AppFlowy Editor is stable at 79 to 80%. Meanwhile, we try to make sure to fix known issues and add new test cases to cover them.
Replacement Approach
We have been actively looking for alternatives in the open-source community, such as super_editor.
During our research, we found that super_editor allows for extending new components in a way that can also support customized shortcuts.
However, the underlying data structure of super_editor is a list that does not support nesting. We feel this data structure is not appropriate for nodes with parent-child relationships. For example, in the case of multi-level lists, the form of each level is inconsistent.
Another important consideration that has factored into our search is our need for an editor that is highly customizable and can keep up with the times such that it can continue to support the evolving functionality of AppFlowy.
To date, we still haven't found a solution that suits our needs.
For the above reasons, we have decided to design and develop the new AppFlowy Editor component ourselves.
Solution Overview
Before starting a new editor project, we'll examine some existing editor implementations. There are not many editor projects based on Flutter, so we'll refer to well-known front-end editor implementations, such as Quill.js and Slate.js.
We believe that the foundation of the editor lies in the design of the data structure.
Quill.js uses Delta as the data structure, while Slate.js uses tree nodes as the data structure. Ultimately we have elected to use a tree node like Slate.js to assemble the documents while continuing to use Delta for the data storage of text nodes.
Why Use a Combination of Node Tree and Delta?
Why do we use a node tree?
- The entirety of the document data is described using a single Delta data which does not allow us to easily describe complex nested scenarios.
- When there is an issue with a paragraph or document, restoring the document becomes relatively difficult.
So our preference is to use a node tree like
Slate.js
to describe the document in chunks, where each chunk’s additions, deletions, and modifications only affect the changes to the current node.Why do we still use Delta for the text node?
- If text with different styles continues to be split into different nodes, it will increase the complexity of the tree node structure.
- The ability to export a text change delta is already supported in Flutter, so it is easy to substitute the Flutter text change delta to
Delta
. - Considering that our previous version is using flutter-quill as the editor component, it is simpler to keep Delta for text nodes in doing a data migration.
Code Example
The following JSON will be used to describe the above-combined data structure.
- For the text node (with a type equal to
text
), the editor will use Delta to store the data. - For the others (non-text nodes), the editor will use Attributes to store the data.
Detailed Design for AppFlowy Editor
We will state the design of AppFlowy Editor through the following three aspects.
- What is the data made of? (keywords: Node, Delta, Document)
- How to update the data? (keywords: Position, Path, Operation, Transaction, EditorState, Apply)
- How to render widgets through the data? (keywords: Render Plugins)
Editor Data Structure
AppFlowy Editor treats a document as a collection of nodes. For example, a paragraph is a
TextNode
and an image is an ImageNode
.We use
LinkedList
to organize the relationship between nodes, which provides a relatively efficient way to insert and delete nodes.Each node uses a normalized description, so we can easily describe those nodes in JSON.
Required Node Fields
A node must contain the fields listed below.
Type
The
Type
field is used to find the renderer and control how to serialize and deserialize the current nodeAttributes
The
Atttributes
field indicates what data should be presented and synced. An ImageNode
, for example, uses the image_src
in its attributes to describe the link where to load the image.Children
The
Children
field indicates the children nodes, such as the embedded bulleted list or the block in the table component.Delta
The Delta field will only be used for instances of
TextNode
.As mentioned above, AppFlowy Editor will use Delta to describe the information of the text node, which is not repeated here.
It should be noted that certain styles are described using
Attributes
instead of Delta
. Rather than make them a part of the text, we treat these styles are descriptions of paragraphs. These styles include headings, references, lists of text nodes, as well as the overall paragraph style.Example Node Definitions
Below is the definition of a
Node
in Dart.1class Node extends ChangeNotifier with LinkedListEntry<Node> {2 Node({3 required this.type,4 Attributes? attributes,5 this.parent,6 LinkedList<Node>? children,7 })8}
While this is an example definition of a
TextNode
in Dart.1class TextNode extends Node {2 TextNode({3 required Delta delta,4 LinkedList<Node>? children,5 Attributes? attributes,6 })7}
Image and Text Node Example
In the following figure, there is an image node and a text node in the document.
The JSON representation of
ImageNode
's data is1{2 "type": "image",3 "attributes": {4 "image_src": "https://i.ibb.co/WKQwVDn/Xnip2022-09-02-15-49-51.jpg",5 "align": "left",6 "width": 2857 }8}
And the JSON representation of
TextNode
's data is1{2 "type": "text",3 "attributes": { "subtype": "heading", "heading": "h1" },4 "delta": [5 { "insert": "🌟 Welcome to AppFlowy!" },6 ]7}
Unordered List Example
In the following figure, you can see an example of an embedded unordered list in the document
And the JSON representation for the document is
1{2 "document": {3 "type": "editor",4 "children": [5 {6 "type": "text",7 "attributes": { "subtype": "heading", "heading": "h3" },8 "delta": [{ "insert": "Bulleted List" }]9 },10 {11 "type": "text",12 "children": [13 {14 "type": "text",15 "attributes": { "subtype": "bulleted-list" },16 "delta": [{ "insert": "A1" }]17 },18 {19 "type": "text",20 "attributes": { "subtype": "bulleted-list" },21 "delta": [{ "insert": "A2" }]22 }23 ],24 "attributes": { "subtype": "bulleted-list" },25 "delta": [{ "insert": "A" }]26 }27 ]28 }29}
Updating Data in the Editor
Before we update the data, we must know which part of the data needs to be updated. In other words, we need to locate the position of a node.
Locating Nodes
Nodes may be located in a variety of manners including:
- Path
- Position
- Selection
Path
AppFlowy Editor uses
Path
to locate the position of a node. Path is an integer array consisting of its position in its ancestor's node and the position of its ancestors. All data change operations are performed based on the Path.1typedef Path = List<int>;
There is an example below.
The path of the first node A is
[0]
, then the path of the next node A1 is [0, 0]
, and so on ...Position
AppFlowy Editor uses position to locate the offset of a node. It consists of a path and an offset.
1class Position {2 final Path path;3 final int offset;4}
Position is usually used for text editing and cursor locating. For example, if we need to locate a caret in the middle of A and 1 in node A1, then the Position is
1Position(path: [0, 0], offset: 1)
Selection
AppFlowy Editor uses
Selection
to represent the range of the selection.The cursor is also a special kind of selection, except that start and end coincide. It consists of two Positions.
1class Selection {2 final Position start;3 final Position end;4}
For example, We need to locate the selection range as shown below.
Then the selection is:
1Selection(2 start: Position(path: [1], offset: 0),3 end: Position(path:[3], offset: 1),4)
Note that selection is directional.
For example, in the case of top-down selection, the selection is
1Selection(2 start: Position(path: [1], offset: 0),3 end: Position(path:[3], offset: 1),4)
And the down-top selection is
1Selection(2 start: Position(path:[3], offset: 1),3 end: Position(path: [1], offset: 0),4)
Operation Types
AppFlowy Editor uses
Operation
objects to manipulate the document data instead of changing the node data directly. All changes to the document are triggered by an Operation
.The operations defined in AppFlowy Editor include
- Insert
- Delete
- Update
- UpdateText
Each operation has a corresponding reverse operation that is applied to undo and redo.
Insert
Insert
represents inserting a list of nodes into the document at a given path. Its reverse operation is Delete
.1class InsertOperation extends Operation {2 final Path path;3 final Iterable<Node> nodes;4}
Take node A1 in the above figure as an example. Inserting a node with the style Bulleted List under the node A1, then the operation is
1{2 "op":"insert",3 "path":[0, 1],4 "nodes":[5 {6 "type":"text",7 "attributes":{"subtype":"bulleted-list"},8 "delta":[]9 }10 ]11}
Delete
Delete
represents deleting a list of nodes into the document at a given path. Its reverse operation is Insert
.1class DeleteOperation extends Operation {2 final Path path;3 final Iterable<Node> nodes;4}
Take the node D in the above figure as an example. Deleting the node D, then the operation is
1{2 "op":"delete",3 "path":[3],4 "nodes":[5 {6 "type":"text",7 "delta":[]8 }9 ]10}
In addition, the node data assigned in the delete operation is for the logic of recovery.
Update
Update
represents updating a node’s attributes at the given path. Its reverse operation is itself.1class UpdateOperation extends Operation {2 final Path path;3 final Attributes attributes;4 final Attributes oldAttributes;5}
Take the node C in the above figure as an example. Converting the type of the node C from a numbered list to a bulleted list, then the operation is
1{2 "op":"update",3 "path":[2],4 "attributes":{"subtype":"bulleted-list"},5 "oldAttributes":{"subtype":"number-list", "number":1}6}
UpdateText
UpdateText
represents updating text delta in the text node, which is consistent with the Delta
logic.For more information, see: https://github.com/quilljs/delta
Transactions
The AppFlowy Editor uses a
Transaction
to describe a set of changes to the document which must be treated as atomic. It consists of a collection of Operation
s and changes to the selection before and after.1class Transaction {2 final List<Operation> operations = [];3 Selection? afterSelection;4 Selection? beforeSelection;5}
The purpose of using
transaction
is to apply a collection of sequential operations that cannot be split apart. For example, in the following case:Pressing the enter key in front of
AppFlowy!
will actually produce two consecutive operations.- operation 1. Insert a new
TextNode
at path[1]
, and set the delta to insertAppFlowy!
- operation 2. Delete
AppFlowy!
at path[0]
.
It can be described in JSON
1{2 "operations":[3 {4 "op":"insert",5 "path":[1],6 "nodes":[{"type":"text","delta":[{"insert":"AppFlowy!"},]}]7 },8 {9 "op":"update_text",10 "path":[0],11 "delta":[{"retain":11},{"delete":9}],12 "inverted":[{"retain":11},{"insert":"AppFlowy!"}]13 }14 ],15 "after_selection":{16 "start":{"path":[1],"offset":0},17 "end":{"path":[1],"offset":0}18 },19 "before_selection":{20 "start":{"path":[0],"offset":11},21 "end":{"path":[0],"offset":11}22 }23}
EditorState and Apply
EditorState
is responsible for managing the state of the document. It holds the Document
, and updates the document data through the apply
function given a Transaction
.1class EditorState {2 void apply(Transaction transaction);3 }
Summary of How Data Changes
EditorState
holds theDocument
, andDocument
is a collection ofNode
objects.- The end-user manipulates a
Node
to generate aSelection
andOperations
, which forms aTransaction
. - Apply
Transaction
toEditorState
to refresh theDocument
.
Rendering Widgets Using the Data
NodeWidgetBuilder
is an abstract protocol, responsible for converting a Node
to a Widget
.1typedef NodeWidgetBuilders = Map<String, NodeWidgetBuilder>;2
3typedef NodeValidator<T extends Node> = bool Function(T node);4
5abstract class NodeWidgetBuilder<T extends Node> {6 NodeValidator get nodeValidator;7
8 Widget build(NodeWidgetContext<T> context);9}
Each node owns its corresponding
NodeWidgetBuilder
.Before initializing AppFlowy Editor, we need to inject the mapping relationship between
Node
and NodeWidgetBuilder
.For now, AppFlowy Editor’s built-in
NodeWidgetBuilder
includes the following1NodeWidgetBuilders defaultBuilders = {2 'editor': EditorEntryWidgetBuilder(),3 'text': RichTextNodeWidgetBuilder(),4 'text/checkbox': CheckboxNodeWidgetBuilder(),5 'text/heading': HeadingTextNodeWidgetBuilder(),6 'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),7 'text/number-list': NumberListTextNodeWidgetBuilder(),8 'text/quote': QuotedTextNodeWidgetBuilder(),9 'image': ImageNodeBuilder(),10};
When AppFlowy Editor starts to render the
Node
s, it will first recursively traverse the Document
.For each
Node
it encounters, the editor will find the corresponding NodeWidgetBuilder
from the mapping relationship according to the nodes’ type and then call the build
function to generate a Widget
.Meanwhile, each
NodeWidgetBuilder
is bound to Node
through ChangeNotifierProvider
. Combined with the above-mentioned logic of Document
data change, whenever the data of a certain node changes, AppFlowy Editor will notify NodeWidgetBuilder
to refresh in real time.Questionnaire
Thanks for reading this article. Please kindly take a 1-minute survey. We would like to collect feedback and learn what interests you the most.
Last but not least, a shoutout to Eric who helps review the article.