Skip to content

How it works: The Editor

Matt Carroll edited this page Apr 12, 2023 · 3 revisions

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.

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

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.

Why is an EditorRequest separate from an EditorCommand?

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

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 EditorCommands?

An EditorCommand 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 EditorCommand 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, EditorCommands 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.

Clone this wiki locally