Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: QuillJS Delta <-> SuperEditor translation with no diffing #1883

Draft
wants to merge 40 commits into
base: main
Choose a base branch
from

Conversation

roughike
Copy link
Contributor

@roughike roughike commented Mar 7, 2024

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.

super_editor_quill.mov

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 \n is done properly.
  • SuperEditor -> Delta for attributions like bold, italics, strikethrough - Need more information what changed in a TextNode when adding/removing/modifying attributions #1884
  • Block elements, such as hr and image - 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 of image:<url>, that should be allowed. Likewise, bold: true by default, but should be customizable to chonky: '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 roughike marked this pull request as draft March 7, 2024 11:08
@roughike roughike changed the title WIP - INCOMPLETE: QuillJS Delta <-> SuperEditor translation WIP - INCOMPLETE: QuillJS Delta <-> SuperEditor translation with no diffing Mar 7, 2024
@roughike roughike changed the title WIP - INCOMPLETE: QuillJS Delta <-> SuperEditor translation with no diffing WIP: QuillJS Delta <-> SuperEditor translation with no diffing Mar 7, 2024
@matthew-carroll
Copy link
Contributor

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

@theniceboy
Copy link

theniceboy commented Mar 10, 2024

@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)

@matthew-carroll
Copy link
Contributor

@theniceboy any user can implement their own EditCommands, so no this isn't the only way.

@theniceboy
Copy link

@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. 👍

@roughike
Copy link
Contributor Author

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

@matthew-carroll
Copy link
Contributor

@roughike I've already done a bit of work/investigation into this. Encoding reverse actions is proving to be a headache.

PR: #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.

@roughike
Copy link
Contributor Author

roughike commented Mar 11, 2024

@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:

  1. an entire document, and:
  2. 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.

@roughike
Copy link
Contributor Author

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

@matthew-carroll
Copy link
Contributor

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?

@roughike
Copy link
Contributor Author

@matthew-carroll I will join the call about 15-20mins late.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants