The editor controller has two major roles: synchronizing the state between the editor and the toolbar and exposing the public API for the editor. Internally the editor mirrors the most important methods of all major modules. In the old Quill codebase this file used to have around 2000 lines of code. We made a significant effort to trim it down bellow 200. Get familiar with all the methods exposed in the public API. It will greatly improve your development abilities.
Quick overview of the entire public API. Just by analysing these methods you can already understand a lot about the editor architecture.
- Constructor - These methods are provided by the client developer.
onReplaceText()
- Invoked each time a character is inserted or removed (it also can be used to insert new objects in the document if we replace the last character)onDelete()
- Invoked when characters are deletedonSelectionCompleted()
- Invoked after release the pointer when selecting textonSelectionChanged()
- Invoked when the selection is changedonBuildComplete()
- Invoked after the layout is fully built. Useful for attaching elements to markers.onScroll()
- Invoked when the editor is scrolling. Useful for updating the position of attached elements. Historyundo()
redo()
- Document
getPlainText()
- Returns plain text for each node within selectionupdate()
- Update editor with a new document.emitEvent
- It defaults to true which means that by default any change (update) made to the document will fire the callbacksonReplaceText()
andonReplaceTextCompleted()
. In certain scenarios when the editor is used in a combination with the state store we might want to disable the emission of a new event. If the state store update the document then we want to prevent the callbacks from being fired. That's when we useemitEvent: false
. If the editor has been changed by the user via typing then we want to push this change to the state store. Then we prefer to emit events (call callbacks) as per default behaviour.
replace()
compose()
- Text Styles
formatTextStyle()
formatText()
formatSelection()
- Applies an attribute to a selection of textgetSelectionStyle()
- Only attributes applied to all characters within this range are included in the resultgetAllIndividualSelectionStyles()
- Returns all styles for each node within selectiongetAllSelectionStyles()
- Returns all styles for any character within the specified text rangegetHeadingsByType()
- Useful for listing the headings of the document in a separate index component.
- Cursor
moveCursorToStart()
moveCursorToPosition()
moveCursorToEnd()
- Selection
updateSelection()
- Nodes
queryNode()
- Given offset, find its leaf node in document
- Highlights
addHighlight()
- Adds a temporary highlights in the document. Cannot be persisted on the serverremoveHighlight()
removeAllHighlights()
- Markers
addMarker()
- Adds a permanent marker in the document. Can be persisted on the servertoggleMarkers()
- Hide or show markerstoggleMarkerByTypeId()
- Hide or show just a certain type of markersgetMarkersVisibility()
- Query if markers are disabledisMarkerTypeVisible()
- Query if certain type of markers are disabledgetAllMarkers()
- Get a list of all markers. Each marker provides the position relative to text and the custom data.deleteMarkerById()
- Delete all markers with the same id from the document.
- Headings
getHeadings()
- Get a list of all headings. The default heading type is H1 but it can be customized in the controller
There are 2 major ares of concern: updating the UI and manipulating the pure data documents. These 2 concerns are split in two major layers: EditorService
and DocumentController
. When exploring the codebase you will find we have compose()
and other methods defined 2 times. Without a clear understanding how the UI and pure data handling are separated you will have a tough time figuring out how they work together.
- EditorController - Exposes a large number of public methods that can query or mutate the document. These methods are mirrored from the various services that are available in the code base.
- EditorService - The most important methods are provided by the
EditorService
. They handle the most important work of updating the document and reflecting the document changes by updating the UI of the editor. - DocumentController - Contains the logic for manipulating the pure data delta document and the logic needed to convert it to a list of nodes that can be rendered by the
DocTreeService
.
Updating The Editor UI vs Document Pure Data Editing
The EditorService
contains the logic that orchestrates the editor UI systems with the DocumentController (pure data editing). It does not contain the document mutation logic. The EditorService
delegates this logic to the DocumentController. The actual document (pure data) editing happens by calling compose()
or other methods from the DocumentController
level. Since both of these are public APIs (service and controller) we had to use short names (less expressive). Therefore to solve the confusion between editorController.compose()
and documentController.compose()
remember that the editor level has to coordinate more systems along with the updates of the document. Which means that the actual pure data editing of the document content happens in documentController.
Hierarchy Of Methods
At first sight you might get quite confused to see editorController.replace()
, editorService.replace()
and DocumentController.replace()
. All of the methods from EditorService
are available in public by being mirrored in the EditorController
. So when we think of EditorService
you can already expect most public methods to be exposed to the public in EditorController
. So all we care about in our analysis is the EditorService
and DocumentController
.
- main.updateEditingValue() - The main class has an override provided by the remote input connection. This override is basically the method that gets invoked each time an user writes something in the editor. Whenever you need to capture/manage/review changes originating from the user inputing text, this is the place.
- editorService.update() - Updates an entire document. It clears the old one and composes the new one in one single step.
- editorService.clear() - Removes the content of the entire document. Uses
editorService.replace()
. - editorService.replace() - Unlike
update()
this method can be used to update only a specific part of the document. It delegates the doc mutation logic todocumentController.replace()
and in some special cases todocumentController.compose()
. It handles invocation of callbacks, triggeringbuild()
and some additional logic related to styling new lines of code when they need to cary over styles form the previous line. - documentController.replace() - Converts to
insert()
anddelete()
and returns a change delta. - documentController.insert() - Runs the insert rules before calling
compose()
. - documentController.delete() - Runs the delete rules before calling
compose()
. - documentController.compose() - The final method that applies changes in the document is
documentController.compose()
. All other methods use it to apply the final computed change set.
Triggering setState()
in the parent widget of the VisualEditor
widget should be avoided as much as possible. Once you built the page, you don't want to trigger a rebuild of the entire editor. Even if Flutter is clever enough to avoid any major work in the rendering layer it still has to run change detection. And for a large document this adds up. Especially on low power devices such as smartphones. To avoid such scenarios it is recommended that you use the controller API to update the document instead of setState()
on the editor parent widget.
- Avoid setState() on text select - One common mistake is to react to the selection change in the editor by setting state in the parent. This will induce a needless build cycle in the editor. For example, in the Markers demo page you can see an editor and bellow it a stats panel with numbers indicating the selection extent. Notice that in the demo page implementation we have made special effort to avoid triggering
setState()
on the entire page when the selection changes. Our solution (one of many possible solutions) was to send the selection extend numbers via a stream to the sibling component that renders them.
For example if you want to have a button that every time is pressed adds a new empty line at the end of the document we can simply replace the last element of the document with the empty line.
final docLen = _controller.document.length;
_controller.replace(docLen - 1, 0, '\n', null);
The attributes are used to apply a different style to a piece of text (bold, italic, etc.). Everything which is not simple text has at least an attribute. To apply attributes without directly interacting with the text (i.e by pressing a button) we can call the format text method from the controller with the desired attribute. Here we apply the h1 attribute to the empty line created above. In this way, by pressing a single button we can create a new empty line with h1 attribute
_controller.formatText(docLen, 0, AttributesAliasesM.h1);
This was an rather peculiar and difficult bug to fix. I've listed here the entire debugging process. Hopefully you can use it to learn a thing or two about the editor controller and the document controller. Now let's jump to the explanation. Calling update()
will trigger two operations: clear()
and compose()
. clear()
will use replace()
to cleanup the entire document until we are left with [{"insert":"\n"}]
. compose()
will then use the new delta to append it to the document. documentController.compose()
will trigger an insert on the rootNode
(nodes list). Reminder: clear()
has updated both the delta and rootNode
to contain an empty line with a simple break line inside. This means we are adding empty rootNode "\n" + new data: "abc\n" and we will get "abc\n\n".
- Attempt1 - Deleting in the controller delta the new line \n character such that we can do "" + "abc\n". This approach has some serious after effects because the delta and the
rootNode
go out of sync. - Attempt2 - Deleting the newline in the rootNode after the insert. However first time it was done the wrong way. I was removing the first child in the list thus leaving the document empty regardless of the delta provided by
update()
. This seems to work fine when you have just an empty field being updated with empty doc. However it no longer works when you attempt to update with a regular document that has chars. Another issue was that I did not update the internal delta of the controller to match the new state of therootNode
. Once again things were going crazy with further interactions due to the mismatch between internal delta and rootNode. - Final Attempt - I realised that I need to delete the last line of the rootNode. Also, we need to make sure this is done ONLY when compose() is called from
clear()
. That's why I created theoverrideRootNode
param. This entire setup might look like a hack, but there's simply no way to get rid of the double \n\n when updating the doc. The entire nodes manipulation code is built under the assumption that a document line will always end with \n. Therefore there's no simple way of getting rid of the initial \n of an empty doc. Thus we are left only with the option presented here: to remove the double \n if we now it was generated by update().