-
Notifications
You must be signed in to change notification settings - Fork 252
How it works: The Editor
THIS DOCUMENT IS A WORK IN PROGRESS
At the heart of super_editor
is an object called the Editor
. This object is the starting point for all changes to document content and surrounding details, such as the current user selection. The Editor
contains a small ecosystem of concepts that all work together to provide a modular, extensible logical document editor. This article describes that ecosystem.
Initially, super_editor
allowed any editor data to be mutated at any time. Unrestricted mutation is convenient, but it poses foundational problems for important features. For example, allowing unrestricted editor mutations makes it impossible to implement undo/redo functionality. It also makes it very difficult, if not impossible, to implement content conversions, such as link conversions, user tags, and hash tags.
To facilitate undo/redo and content replacement, we decided to force all editor data mutations through a single pipeline. This change is only a minor inconvenience as compared to the original implementation, but this change lets us ship undo/redo support out-of-the-box, and it opens the door for any given content replacement that an app might choose to implement.
The Editor
class implements what we call the "editor pipeline". Every data change, such as the insertion of a character, deletion of a word, conversion of a line to a list item, and insertion of an image, begins as a request that flows into this pipeline, and completes with an event log flowing out of this pipeline.
The pieces of the editor pipeline are as follows:
EditRequest: Something you want to happen, e.g., "insert this character"
EditCommand: Alters various objects to make something happen, e.g., "inserts a given character into a paragraph", and produces a corresponding change list of EditEvent
s.
EditEvent: A record of something that happened, e.g., "a character was inserted".
EditReaction: Requests additional changes, after an EditCommand
completes, e.g., recognizes a URL and requests to convert that URL into a link.
EditListener: Takes an action based on the results of an EditCommand
, but doesn't request any further changes to the editor.
A request and command are conceptually very similar. It's tempting to combine the two concepts into one. However, there's value in separating what you want to happen, from how it happens.
An EditCommand
is responsible for understanding the concrete implementation of whatever it's mutating. For example, this means understanding the app's specific implementation of the Document
. One app might implement an in-memory Document
, while another app implements a Document
that's backed by a database. One app might implement a Document
that only exists on the client-side, while another app might implement shared Document
editing across many clients and a server.
If requests and commands were combined, then every app that implements their own Document
would also need to redeclare basic concepts like InsertTextRequest
, DeleteCharacterRequest
, InsertImageRequest
. Additionally, every app would need to define their own keyboard handlers and IME handlers so that those apps could integrate their custom requests. Pretty soon, apps would find themselves rewriting the majority of the editor experience, just because they needed a custom Document
implementation.
To avoid significant extra work by app developers, super_editor
separates the abstract concept of a "request" from the concrete implementation of a "command".
You can think of an EditReaction
as an EditListener
plus more edits. Or, you can think of an EditListener
as an EditReaction
without more edits. The two are very similar.
We chose to separate these concepts for clarity of purpose, and so that we leave the door open for future behaviors that need to know whether or not additional edits will occur.
The purpose of an EditReaction
is inward-facing. An EditReaction
waits for a relevant edit to take place, and then an EditReaction
adds another edit in response. That behavior is entirely about the editor and its content.
The purpose of an EditListener
is outward-facing. An EditListener
propagates edit information to other areas of the app, which might be interested.
An EditCommand
applies a change to a Document
, and other objects. So why do we need EditEvent
s? We need EditEvent
s to solve two problems.
First, we need EditEvent
s so that EditReaction
s can inspect what changed, and decide whether or not to react. In the absence of EditEvent
s, an EditReaction
would only be able to react based on the new state of the Document
, and other objects. An EditReaction
would have no idea what data was present before the EditCommand
was executed. But with a list of EditEvent
s, every EditReaction
has a receipt of every change that was made, and the EditReaction
can use that information to decide whether or not to react.
Second, we need EditEvent
s to implement undo/redo. Undo/redo requires a ledger of changes. Undoing an edit means moving backwards in that ledger. Redoing an edit means moving forward in that ledger. In theory, EditCommand
s could be stored in that ledger. However, we felt it was a prudent decision to separate "how" something is changed from "what was changed", i.e., separating behavior from data. By defining EditEvent
s as data structures, it's easy to serialize edit history in a format like JSON, which then makes it easy to transmit to a server, or store in a local file.
EditCommand
s are responsible for doing the real work during an edit. EditCommand
s alter the state of various objects within the editor experience. For example, when a user wants to type a character, an EditCommand
will insert that character into the text within a TextNode
, and also update the DocumentComposer
with a new selection so that the caret moves one character forward.
But, how does an EditCommand
get a reference to a MutableDocument
and a DocumentComposer
?
Every object that might be altered during the editing process is available through an object called the EditContext
. For example, an EditCommand
might obtain a MutableDocument
as follows:
final document = editContext.find<MutableDocument>("document");
An EditContext
is an implementation of a "service locator". You can also think of it as a glorified map from names to objects.
The app developer is responsible for putting everything in the EditContext
that any EditCommand
might need to do its job. At a minimum, this probably includes a MutableDocument
and a DocumentComposer
.
Every object in the EditContext
must implement Editable
. Editable
is an interface that's used to acknowledge when a transaction begins and ends:
abstract class Editable {
void onTransactionStart();
void onTransactionEnd(List<EditEvent> changeList);
}
These transaction APIs are important for two reasons.
First, an Editable
, such as a MutableDocument
, shouldn't broadcast any changes during a transaction. This is because objects might be an inconsistent or illegal state during a transaction. There aren't any promises of stability until the transaction is finished.
Second, once the transaction is finished, the Editable
should only broadcast changes to its listeners, if the Editable
actually changed. For this reason, every Editable
is given the list of things that changed. As a result, a DocumentComposer
, for example, can check the changeList
, and only if a selection change occurred, would the DocumentComposer
notify its listeners of a selection change.