-
Notifications
You must be signed in to change notification settings - Fork 281
How it works: The Editor
THIS DOCUMENT IS A WORK IN PROGRESS
At the heart of super_editor
is an object called the DocumentEditor
. This object is the starting point for all changes to document content and surrounding details, such as the current user selection. The DocumentEditor
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 DocumentEditor
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:
EditorRequest: Something you want to happen, e.g., "insert this character"
EditorCommand: 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 a EditorCommand
completed, e.g., recognizes a URL and requests to convert that URL into a link.
EditListener: Takes an action based on the results of a EditorCommand
, 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 EditorCommand
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 EditorCommand
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 EditorCommand
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, EditorCommand
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.