Skip to content

How it works: The Editor

Matt Carroll edited this page May 13, 2023 · 3 revisions

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.

Why was this editor structure created?

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 pipeline

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 EditEvents.

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.

Why is an EditRequest separate from an EditCommand?

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".

Why is EditReaction separate from EditListener?

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.

Why are EditEvents generated by EditCommands?

An EditCommand applies a change to a Document, and other objects. So why do we need EditEvents? We need EditEvents to solve two problems.

First, we need EditEvents so that EditReactions can inspect what changed, and decide whether or not to react. In the absence of EditEvents, 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 EditEvents, 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 EditEvents 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, EditCommands 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 EditEvents 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.

EditCommands, EditContext, and Editables

EditCommands are responsible for doing the real work during an edit. EditCommands 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.