super_editor
super_editor copied to clipboard
WIP: QuillJS Delta <-> SuperEditor translation with no diffing
A very long-running PR for a plug-in SuperEditor <-> QuillJS Delta translation layer.
Here be dragons - don't use this yet and don't bother reviewing it. It's very early and a lot of things will change.
https://github.com/superlistapp/super_editor/assets/13744304/e943f113-bdd2-435c-8807-9a362af885e0
TBD - incomplete list, will be updated
🚧 (in progress): Delta -> SuperEditor for attributions (bold, italics, underline, custom attribute) - can be done today, no blockers.
- [ ] Make sure that multiline plain text editing really works and
\nis done properly. - [ ] SuperEditor -> Delta for attributions like bold, italics, strikethrough - https://github.com/superlistapp/super_editor/issues/1884
- [ ] Block elements, such as
hrandimage- should be customizable. A task element should not be built-in, but it should be easy to create a custom task element. - [ ] Have sane defaults for attributions and elements, but allow customizing them. If someone wants to do
img: <url>instead ofimage:<url>, that should be allowed. Likewise,bold: trueby default, but should be customizable tochonky: 'yes'or whatever if someone wants to do so. - [ ] Have a nicer API - will evolve during the lifetime of this PR.
- [ ] Probably a lot of more things to do. Update the list when appropriate.
What is this?
It's a new package called super_editor_quill - a two-way translation layer between SuperEditor EditEvents and QuillJS Deltas that does not require document diffing.
It allows us to generate QuillJS Deltas from SuperEditor EditEvents. It also allows us to convert a QuillJS Delta to SuperEditor EditRequests that can be applied to the SuperEditor document.
There's an example app where there's a SuperEditor and a QuillJS editor side-by-side. Editing the SuperEditor document contents updates the Quill editor contents and vice-versa. All edits are also displayed in a list. This allows us to test that the translation works in practice.
What is it not?
This package does not handle conflict resolution or Operational Transformation in any way. It just converts SuperEditor EditEvents to Deltas and Deltas to SuperEditor EditRequests.
It's conceptually the same as SuperEditor Document <-> Markdown converter - things are converted to one format and back. The only distinction is that Markdown represents a whole document, but a Quill Delta can represent a document and a surgical change in a document. This package deals with the latter.
Although this package does not solve any of the Operational Transformation parts of the equation, it's one very significant building block of it.
Current API (subject to change most likely)
class _MyAppState extends State<MyApp> {
late final MutableDocument _document;
late final MutableDocumentComposer _composer;
late final Editor _editor;
@override
void initState() {
super.initState();
_document = MutableDocument.empty(widget.paragraphNodeId);
_composer = MutableDocumentComposer();
_editor = Editor(
editables: {
Editor.documentKey: _document,
Editor.composerKey: _composer,
},
requestHandlers: [...defaultRequestHandlers],
);
// Listen to delta changes in the document.
final deltaChangeListener = DeltaDocumentChangeListener(
peekAtDocument: () => MutableDocument(nodes: List.unmodifiable(_document.nodes)),
onDeltaChangeDetected: (change) {
// Assumes that `pushDocumentChange` tags the change with an appropriate document
// version and does Operational Transformation properly. OT is out of scope for `super_editor_quill`.
_myRemoteService.pushDocumentChange(change);
},
);
_document.addListener(deltaChangeListener.call);
// Applies remote delta changes to document as they come.
//
// Assumes that the `documentChanges()` is a `Stream<Delta>` that is properly transformed
// using Operational Transformation. OT is out of scope for `super_editor_quill`.
// TODO: selection transformation?
const deltaApplier = DeltaApplier();
_myRemoteService.documentChanges().listen(deltaApplier.apply);
}
@override
void dispose() {
_document.dispose();
_composer.dispose();
_editor.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SuperEditor(
editor: _editor,
document: _document,
composer: _composer,
);
}
}
@roughike FYI - as I've investigated approaches to undo/redo, I'm close to coming to the conclusion that perhaps the easiest way to achieve state jumps is to internally use Quill Deltas as a memento representation. I imagine that would greatly reduce the need for any kind of additional or external mapping to Deltas.
Would you like to discuss this?
@matthew-carroll @roughike Is it correct to say that this PR would provide the only way that one can achieve collaborative editing with SuperEditor?
Edit: (Simply)
@theniceboy any user can implement their own EditCommands, so no this isn't the only way.
@theniceboy any user can implement their own
EditCommands, so no this isn't the only way.
Got it. I was curious if SuperEditor implements a well established OT format. The Quill Delta format, for example, has support in many backend languages and it's easy to implement collaborative editing because a simple controller.compose(updateDelta) will do the trick. I look forward to when this PR gets merged. 👍
@matthew-carroll FYI - as I've investigated approaches to undo/redo, I'm close to coming to the conclusion that perhaps the easiest way to achieve state jumps is to internally use Quill Deltas as a memento representation.
From my perspective, Quill Deltas are one way to implement undo/redo, but totally not needed.
I think one good way to implement undo/redo is as follows:
- Every time the document changes, store the inverted version of that change in the undo stack. If the user inserts "c" at position 2, the inverted change is "delete character (c) at position 2".
- When the user requests undo, pop the most recent entry from the undo stack and apply it to the document. Invert the change and store it in the redo stack. For example, if the change was "delete character (c) at position 2", the inverted version of that change is "insert character (c) at position 2".
- When the user types manually in the document, the redo stack is cleared.
- Ideally, there will also be some kind of throttle delay that merges recent changes into one change. "Insert character (c) at position 2" and "insert character (d) at position 3" will be merged into "insert characters (cd) at position 2".
For example:
class UndoRedoStack {
final _undoStack = <EditRequest>[];
final _redoStack = <EditRequest>[];
void recordDocumentChange(EditRequest change) {
// Add the inverted version of the change into the undo stack.
_undoStack.add(change.invert());
// Since redo is only available after the user undoes operations, we should
// clear the redo stack every time the user changes the document manually.
_redoStack.clear();
}
EditRequest? undo() => _applyChange(_undoStack, _redoStack);
EditRequest? redo() => _applyChange(_redoStack, _undoStack);
EditRequest? _applyChange(
List<EditRequest> source,
List<EditRequest> destination,
) {
if (source.isEmpty) return null;
final change = source.removeLast();
destination.add(change.invert());
// Return the EditRequest that should be applied to the document to perform
// the desired undo/redo operation.
return change;
}
}
This would already implement basic undo/redo functionality. Only thing needed is the invert() functionality for EditRequests - it could be enforced in the EditRequest class by adding EditRequest invert() method to it. There's a change that it has to be EditRequest invert(Document document) so that there's enough context to invert requests.
Adding throttling and composing edit requests together within a timeframe could be done with yet another method EditRequest compose(EditRequest other).
You can basically copy-paste this, change Delta to EditRequest, implement inverting and composing requests, and it would be pretty much work as-is: https://github.com/roughike/super_editor_collaboration_sample/blob/e6b7fd71991fbe01271a9235a0782c8b3b6eedf4/client/lib/local_document_history.dart
One other thing is being able to "transform the stacks". If undo stack has a single change, "delete (c) from position 2", and a remote user inserts "X" in the beginning of the document, then "delete (c) from position 2" has to become "delete (c) from position 3" so that the collaboration works properly. It's a bit out of scope for a general purpose editor, but it's already implemented in the Delta version of undo/redo I linked above. It would require yet another EditRequest transform(int shiftBy) method though.
tl;dr: Undo/redo does not require Quill, and I'm not sure if using Quill will make it easier. We'd still need to translate from Quill <-> SuperEditor. After my PR lands, I think it would be easier to just drop-in my Quill undo/redo stack, but there would be a lot of back-and-forth Quill <-> SuperEditor translation ping-pong there. The undo/redo stack could and should probably be implemented without Quill.
@roughike I've already done a bit of work/investigation into this. Encoding reverse actions is proving to be a headache.
PR: https://github.com/superlistapp/super_editor/pull/1881
Write-up: https://github.com/superlistapp/super_editor/wiki/Design-Thoughts:-Undo-Redo
I'm at the point where I'm considering using Deltas so that all changes can be serialized, such that those changes can be played back to move to an earlier history. I understand that Quill Deltas aren't necessary - it could be any delta format. But if I'm going to implement a delta format, I would think that using the Quill Delta format would have the most crossover value in terms of what Superlist needs, as well as other clients who have asked for Delta support.
@theniceboy Is it correct to say that this PR would provide the only way that one can achieve collaborative editing with SuperEditor?
Edit: (Simply)
This PR only adds support for translating SuperEditor documents and changes on SuperEditor documents into Quill Deltas.
There's no collaboration support coming in this PR - Quill Deltas are just a way to represent:
- an entire document, and:
- a change in a document.
Quill Deltas by themselves don't add collaboration capabilities to SuperEditor.
So in a way, this PR is not much different from a Markdown <-> SuperEditor converter (except that Markdown does not have a way of saying "insert (a) at position 2"). Whenever this PR ready, SuperEditor will still have no collaboration support.
However, if you dig a bit into Quill Deltas, you'll find that the Delta format is specifically built for Google Docs style realtime collaboration support with Operational Transformation. So having a Quill Delta support for SuperEditor is one building block for supporting realtime collaboration with OT, as it's a really nice data format and an utility library for that.
@matthew-carroll I'm at the point where I'm considering using Deltas so that all changes can be serialized, such that those changes can be played back to move to an earlier history. I understand that Quill Deltas aren't necessary - it could be any delta format.
To me, EditRequest (or EditEvent or whatever is the most fitting here) is already a kind of a delta format.
Playing back EditRequests from the inception of the document will result in the most recent document state. Playing back inverted EditRequests in reverse order on top of the current document can undo the document changes one-by-one.
I will take a look at what you've written in detail, but I'm also up for brainstorming about this.
Playing back EditRequests from the inception of the document will result in the most recent document state. Playing back inverted EditRequests in reverse order on top of the current document can undo the document changes one-by-one.
I think that most/all of the complexity is in the "playing back" part. Maybe we can chat on Wed?
@matthew-carroll I will join the call about 15-20mins late.
Will reopen once I get the chance to actively work on it again