-
Notifications
You must be signed in to change notification settings - Fork 281
Design Thoughts: Undo Redo
Text editors require undo/redo support. The undo/redo behavior lets the user reverse one or more recent operations, and also lets the user redo one or more recent undo's. This behavior can be viewed as moving backward and forward through a ledger that holds the user's editing history.
It's worth noting the variety of information that might be captured in editing history. Obviously, changes to text are part of that history. Such changes include insertions, deletions, and style changes.
Related to text changes are selection changes. Users expect that when text changes are reversed, the caret is moved back to its previous position, too. Moreover, from a correctness standpoint, if the caret wasn't moved during undo operations, then the caret could end up in a non-existent location, which would be an error condition. Therefore, the caret/selection must also be tracked by the undo/redo system.
Super Editor isn't a plain-text editor, it's a rich text editor. Super Editor ships with support for images, videos, and horizontal rules. Super Editor can also be extended to support arbitrary content, such as tables and code editors. These media types might have any number of editable details. Images, videos, and horizontal rules probably only support insertion and deletion. But a table essentially includes a mini-document within every cell, and also includes the ability to add, remove, re-size, and re-arrange rows and columns. An embedded code editor includes its own entire editing system. There's an unbounded variety of both data models and data changes that are associated with possible Super Editor content.
Whatever approach Super Editor chooses for undo/redo, it needs to work equally well for content added by 3rd parties as it does for content changes that ship with Super Editor. The approach can't depend upon a bounded set of possible edit operations.
There seem to be two common approaches to implementing undo/redo. Both options are based on tracking a series of objects, but the thing that is tracked differs between the two. One option is to track the "commands" that alter the document state. The other option is to track the "state changes" that occur within the document state over time.
Commands are Dart objects that implement a change. For example, there might be an InsertTextCommand
, which knows how to insert text into the document:
// Pseudo-code:
class InsertTextCommand implements Command {
InsertTextCommand(this.text, this.insertPosition);
final String text;
final DocumentPosition insertPosition;
void execute(...) {
document.getNodeAt(insertPosition).insertText(text);
}
}
Commands exist in the Super Editor implementation no matter which undo/redo approach is chosen. However, one of the approached to undo/redo would collect all executed commands in a stack.
class Editor {
final _history = <Command>[];
void execute(Command command) {
command.execute(...);
_history.add(command);
}
}
If we add an undo
behavior to Command
, we can use the stack of Command
s to undo/redo changes.
class Editor {
final _history = <Command>[];
void undo() {
final lastCommand = _history.removeLast();
lastCommand.undo(...);
_future.add(lastCommand);
}
void redo() {
final futureCommand = _future.removeLast();
futureCommand.execute(...);
_history.add(futureCommand);
}
}
class InsertTextCommand implements Command {
InsertTextCommand(this.text, this.insertPosition);
final String text;
final DocumentPosition insertPosition;
void execute(...) {...}
void undo(...) {
document.getNodeAt(insertPosition).deleteTextAt(insertPosition, text.length);
}
}