Upgrade to Pro — share decks privately, control downloads, hide ads and more …

From single player to multiplayer - Building re...

Iiro Krankka
September 01, 2022

From single player to multiplayer - Building realtime collaborative text editing experiences with Dart

Superlist uses SuperEditor, a modular rich text editor for Flutter, to enable our users to do powerful task management in the context of rich text documents.

SuperEditor has everything we need to create that experience. But as we’re in a connected world, editing shouldn’t be done in isolation.

So how do we turn an editing toolkit like SuperEditor, that has no “multi-player” capabilities into a full collaborative experience? SuperEditor doesn’t have an opinion on how documents should be serialized, and as such, it doesn't have built-in support for collaborative editing or conflict resolution.

But collaboration is a must have, not nice to have. What gives? In this talk, we’re going over how we made SuperEditor work for real time collaboration, while still keeping SuperEditor unopinionated.

Iiro Krankka

September 01, 2022
Tweet

More Decks by Iiro Krankka

Other Decks in Programming

Transcript

  1. Who are you? • iiro with two i’s • Started

    a blog about Flutter in 2017 called flutter.rocks ◦ Now called just “iiro.dev” • Fluttering professionally for more than 5 years: ◦ 2017-2018: Codemate/Rebel App Studio ◦ 2018-2021: Frontend Lead at Reflectly ◦ Now Principal Engineer at Superlist
  2. Superlist • “The next generation productivity tool for individuals and

    teams.” • superlist.com/open-source ◦ SuperEditor ◦ SuperNativeExtensions - full-featured clipboard, drag and drop ◦ SuperAudio • superlist.com/careers ◦ We’re looking for Flutter developers!
  3. How do we do this? Just go to pub.dev and

    import `package:document_mode`?
  4. “Poor mans” document collaboration • Firebase Cloud Messaging to broadcast

    changes to everyone • Send the whole document each time when even one character changes ◦ Debounced, of course • “Last write wins”
  5. Problems with “last write wins” • Pretty okay when people

    don’t type at the same time. Completely unusable when two or more people type at the same time • Problems: ◦ My cursor jumping around when other users edit the document ◦ I lose a lot of progress quite often due to other users overriding my changes ◦ Oftentimes, User A and User B ended in different states • It’s not nice to send the entire document instead of sending just the changes • There’s got to be a better way?
  6. Part 1: Quill Delta format This part you can switch

    if you like, but it makes things really, really, really so much simpler.
  7. import 'package:quill_delta/quill_delta.dart' ; final justASample = Delta() ..retain(5) ..insert('hello') ..delete(5);

    Is the same as: final justASample = [ {'retain': 5}, {'insert': 'hello'}, {'delete': 5}, ]; A Quill Delta is just a list of Map structures expressible as JSON
  8. final document = Delta() ..insert('Hello world!\n') ..insert('This is a paragraph.\n')

    ..insert('This is also a paragraph!\n') ..insert({'image': 'brent_rambo.gif'}); Babby’s first Document Delta
  9. final document = Delta() // Paragraph with bold and italic

    text attributes. ..insert('It was a ') ..insert('bold', {'bold': true}) ..insert(' and ') ..insert('italic', {'italic': true}) ..insert(' night!\n') // An image embed with an alt text attribute. ..insert( {'image': 'brent_rambo.gif' }, { 'alt_text': 'Brent Rambo approving of something he sees on the computer screen.' }, ); Quill Delta format - Document Deltas - Attributes
  10. final document = Delta() ..insert('It was a ') ..insert( 'bold

    and italic', { 'bold': true, 'italic': true, } ) ..insert(' night!\n'); You can have multiple attributes on the same piece of text
  11. final document = Delta() ..insert('It was a very ') ..insert('colorful',

    {'with_unicorn_pooping_rainbows': true}) ..insert(' night!\n'); Quill Delta format - Document Deltas - Attributes
  12. final change = Delta() // Skip the first 6 characters

    in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // At the current position, which is after // the word "people", delete the next 5 // characters. ..delete(5); Quill Delta format - Change Deltas
  13. final change = Delta() // Skip the first 6 characters

    in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // At the current position, which is after // the word "people", delete the next 5 // characters. ..delete(5); Quill Delta format - Change Deltas
  14. final change = Delta() // Skip the first 6 characters

    in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // At the current position, which is after // the word "people", delete the next 5 // characters. ..delete(5); Quill Delta format - Change Deltas
  15. final change = Delta() // Skip the first 6 characters

    in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // At the current position, which is after // the word "people", delete the next 5 // characters. ..delete(5); Quill Delta format - Change Deltas
  16. final a = Delta()..insert('Hello world!\n'); Delta.compose() What if we want

    to change this into “Hello people!” instead?
  17. final a = Delta()..insert('Hello world!\n'); final change = Delta() //

    Skip the first 6 characters in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // Delete the next 5 characters. ..delete(5); // Delta()..insert('Hello people!\n'). final b = a.compose(change); Delta.compose() - changing “Hello world!” to “Hello people!”
  18. final a = Delta()..insert('Hello world!\n'); final change = Delta() //

    Skip the first 6 characters in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // Delete the next 5 characters. ..delete(5); // Delta()..insert('Hello people!\n'). final b = a.compose(change); Delta.compose() - changing “Hello world!” to “Hello people!”
  19. final a = Delta()..insert('Hello world!\n'); final change = Delta() //

    Skip the first 6 characters in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // Delete the next 5 characters. ..delete(5); // Delta()..insert('Hello people!\n'). final b = a.compose(change); Delta.compose() - changing “Hello world!” to “Hello people!”
  20. final a = Delta()..insert('Hello world!\n'); final change = Delta() //

    Skip the first 6 characters in the document. ..retain(6) // Insert the text "people" at current position. ..insert('people') // Delete the next 5 characters. ..delete(5); // Delta()..insert('Hello people!\n'). final b = a.compose(change); Delta.compose() - changing “Hello world!” to “Hello people!”
  21. Client A Client B Version: 0 (empty) Version: 0 Version:

    0 ver: 0 ins(“abc”) Delta() .compose(Delta()..insert(“abc”))
  22. Client A Client B Version: 1 ins(“abc”) Version: 1 Version:

    0 ver: 0 ins(“123”) ver: 0 ins(“abc”)
  23. Client A Client B Version: 1 ins(“abc”) Version: 1 Version:

    0 ver: 0 ins(“123”) ver: 0 ins(“abc”) compose()
  24. If “a” reached the server first, how to make sure

    that “b” is up to date? final a = Delta()..insert('abc'); final b = Delta()..insert('123'); // Delta()..retain(3)..insert('123') final bTransformed = a.transform(b, true);
  25. If “a” reached the server first, how to make sure

    that “b” is up to date? final a = Delta()..insert('abc'); final b = Delta()..insert('123'); // Delta()..retain(3)..insert('123') final bTransformed = a.transform(b, true);
  26. // All the changes that have been applied to the

    server document. final serverChanges = [ Delta()..insert('abc'), ]; // The change that Client B wants to make. Targeting old // version of the document where the whole document was empty. final oldClientBChange = Delta()..insert('123'); // The Client B change can be brought up to date by transforming // it with the server changes. // // Result: Delta()..retain(3)..insert('123') final newClientBChange = serverChanges.fold<Delta>( oldClientBChange, (previous, current) => previous.transform(current , true), );
  27. Client A Client B Version: 1 Contents: “abc” Version: 1

    ver: 0 ins(“123”) Delta.transform() magic Version: 1
  28. Client A Client B Version: 1 Contents: “abc” Version: 1

    ver: 1 ret(3), ins(“123”) Version: 2
  29. Client A Client B Version: 1 Contents: “abc” Version: 1

    ver: 1 ret(3), ins(“123”) Version: 2
  30. Client A Client B Version: 1 Contents: “abc” Version: 1

    ver: 1 ret(3), ins(“123”) Version: 2 (Delta()..insert(“abc”)) .compose( Delta() ..retain(3) ..insert(“abc”) )
  31. Client A Client B Version: 2 Contents: “abc123” Version: 1

    ver: 1 ret(3), ins(“123”) Version: 2
  32. Client A Client B Version: 2 Contents: “abc123” Version: 1

    ver: 1 ret(3), ins(“123”) Version: 2 compose()
  33. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion, required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange, (previous, current) => current.transform(previous, true), ); contents = contents.compose(transformedChange); version++; changes.add(transformedChange); } }
  34. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion, required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange, (previous, current) => current.transform(previous, true), ); contents = contents.compose(transformedChange); version++; changes.add(transformedChange); } }
  35. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion , required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion ; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange, (previous, current) => current.transform(previous, true), ); contents = contents.compose(transformedChange); version++; changes.add(transformedChange); } }
  36. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion , required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange , (previous, current) => current.transform(previous , true), ); contents = contents.compose(transformedChange); version++; changes.add(transformedChange); } }
  37. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion , required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange, (previous, current) => current.transform(previous, true), ); contents = contents.compose(transformedChange) ; version++; changes.add(transformedChange) ; } }
  38. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion , required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange, (previous, current) => current.transform(previous, true), ); contents = contents.compose(transformedChange); version++; changes.add(transformedChange) ; } }
  39. class Server { Delta contents = Delta(); var version =

    0; final changes = <Delta>[]; void update({required int clientVersion , required Delta clientChange}) { // Check how far behind client is. final changeCount = version - clientVersion ; // Transform the client change with server changes. final transformedChange = changes .sublist(changes.length - changeCount) .fold<Delta>( clientChange , (previous, current) => current.transform(previous , true), ); contents = contents.compose(transformedChange) ; version++; changes.add(transformedChange) ; } }
  40. Caveats • The code is simplified • We actually use

    Elixir for our backend • This code does not have error handling • It also doesn’t prevent clients from corrupting documents ◦ Might sometimes happen, but shouldn’t if everything is implement correctly ◦ Still a good idea to prevent corruptions on the server!
  41. class DocumentSyncEngine { DocumentSyncEngine(this._documentChannel); final DocumentChannel _documentChannel; int? _version; Delta?

    _currentDocument; void openDocument({required String documentId}) { … } void onLocalDocumentChanged (Delta document) { … } }
  42. void _handleRemoteDocumentOpened (int version, Delta contents) { _version = version;

    _currentDocument = contents; _updateEditor(contents); } When the server connection is initially established
  43. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument !.diff(newDocument)

    ; if (change.isNotEmpty) { _currentDocument = _currentDocument !.compose(change) ; await _documentChannel .sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  44. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument !.diff(newDocument)

    ; if (change.isNotEmpty) { _currentDocument = _currentDocument!.compose(change); await _documentChannel.sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  45. final a = Delta()..insert( 'Hello world! \n'); Delta.diff() ? final

    b = Delta()..insert( 'Hello people! \n'); to this: What’s the most minimal change from this:
  46. final a = Delta()..insert( 'Hello world! \n'); final b =

    Delta()..insert( 'Hello people! \n'); // Delta() // ..retain(6) // ..insert('people') // ..delete(5); final change = a.diff(b); Delta.diff()
  47. final a = Delta()..insert( 'Hello world! \n'); final b =

    Delta()..insert( 'Hello people! \n'); // Delta() // ..retain(6) // ..insert('people') // ..delete(5); final change = a.diff(b) ; Delta.diff()
  48. final a = Delta()..insert( 'Hello world! \n'); final b =

    Delta()..insert( 'Hello people! \n'); // Delta() // ..retain(6) // ..insert('people') // ..delete(5); final change = a.diff(b) ; // Will be exactly the same as "b". a.compose(change) ; Delta.diff()
  49. final a = Delta()..insert( 'Hello world! \n'); final b =

    Delta()..insert( 'Hello people! \n'); // Delta() // ..retain(6) // ..insert('people') // ..delete(5); final change = a.diff(b) ; // Will be exactly the same as "b". a.compose(change) ; Delta.diff()
  50. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument !.diff(newDocument)

    ; if (change.isNotEmpty) { _currentDocument = _currentDocument!.compose(change); await _documentChannel.sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  51. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument!.diff(newDocument); if

    (change.isNotEmpty) { _currentDocument = _currentDocument !.compose(change) ; await _documentChannel .sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  52. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument!.diff(newDocument); if

    (change.isNotEmpty) { _currentDocument = _currentDocument !.compose(change) ; await _documentChannel.sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  53. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument!.diff(newDocument); if

    (change.isNotEmpty) { _currentDocument = _currentDocument!.compose(change); await _documentChannel .sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  54. void onLocalDocumentChanged (Delta newDocument) { final change = _currentDocument!.diff(newDocument); if

    (change.isNotEmpty) { _currentDocument = _currentDocument!.compose(change); await _documentChannel.sendUpdate( documentId: _documentId, version: _version, change: change, ); _version = version! + 1; } } When a user edits the document
  55. Future<void> _pushLocalUpdate (Delta change) async { if (_changesBeingCurrentlyPushed != null)

    { _queuedChanges ??= Delta(); _queuedChanges = _queuedChanges !.compose(change) ; } else { final version = _version; _version = version! + 1; _changesBeingCurrentlyPushed = change; await _documentChannel .sendUpdate(documentId: _documentId , version: version , change: change) ; _changesBeingCurrentlyPushed = null; if (_queuedChanges != null) { _pushLocalUpdate( _queuedChanges !); _queuedChanges = null; } } }
  56. void _handleRemoteDocumentChanged (int version, Delta change) { _currentDocument = _currentDocument!.compose(change);

    _version = _version! + 1; _updateEditor(_currentDocument!, change); } When another user edits the document
  57. void _handleRemoteDocumentChanged (int version , Delta change) { var remoteDelta

    = Delta.from(change); if (_changesBeingCurrentlyPushed != null) { remoteDelta = _changesBeingCurrentlyPushed !.transform(remoteDelta , false); if (_queuedChanges != null) { final remotePending = _queuedChanges !.transform(remoteDelta , false); _queuedChanges = remoteDelta.transform( _queuedChanges !, true); remoteDelta = remotePending ; } } _currentDocument = _currentDocument !.compose(remoteDelta) ; _version = _version! + 1; _updateEditor (_currentDocument !, remoteDelta) ; }
  58. Scenario A: Without selection transformation 😡😡😡 GIF: Right client has

    collapsed selection after “f”, left client adds 4 x’s at the start. Selection stays collapsed at offset 6.
  59. Scenario B: Without selection transformation 😡😡😡 GIF: Right client has

    selected the entire document, left client adds 4 x’s in the middle. The selection stays the same - start: 0, end: 6.
  60. // We have a collapsed selection after the letter "f".

    const oldSelection = DocumentSelection (start: 6, end: 6); // Server notifies us that some other user inserts 4 x's // at the start of the document. final remoteChange = Delta()..insert('xxxx'); // With the `.transformPosition()` utility method, we update our // selection to match the new document after composing `remoteChange`. final newSelection = DocumentSelection( start: remoteChange.transformPosition(oldSelection.start), end: remoteChange.transformPosition(oldSelection.end), ); // Tell the editor to update the selection. // // It will still be collapsed and after the letter "f", but new // position will be 10 instead 6. _updateEditorSelection(newSelection);
  61. // We have a collapsed selection after the letter "f".

    const oldSelection = DocumentSelection(start: 6, end: 6); // Server notifies us that some other user inserts 4 x's // at the start of the document. final remoteChange = Delta()..insert('xxxx'); // With the `.transformPosition()` utility method, we update our // selection to match the new document after composing `remoteChange`. final newSelection = DocumentSelection( start: remoteChange.transformPosition(oldSelection.start), end: remoteChange.transformPosition(oldSelection.end), ); // Tell the editor to update the selection. // // It will still be collapsed and after the letter "f", but new // position will be 10 instead 6. _updateEditorSelection(newSelection);
  62. // We have a collapsed selection after the letter "f".

    const oldSelection = DocumentSelection(start: 6, end: 6); // Server notifies us that some other user inserts 4 x's // at the start of the document. final remoteChange = Delta()..insert('xxxx'); // With the `.transformPosition()` utility method, we update our // selection to match the new document after composing `remoteChange`. final newSelection = DocumentSelection ( start: remoteChange.transformPosition(oldSelection. start), end: remoteChange.transformPosition(oldSelection. end), ); // Tell the editor to update the selection. // // It will still be collapsed and after the letter "f", but new // position will be 10 instead 6. _updateEditorSelection(newSelection);
  63. // We have a collapsed selection after the letter "f".

    const oldSelection = DocumentSelection(start: 6, end: 6); // Server notifies us that some other user inserts 4 x's // at the start of the document. final remoteChange = Delta()..insert('xxxx'); // With the `.transformPosition()` utility method, we update our // selection to match the new document after composing `remoteChange`. final newSelection = DocumentSelection( start: remoteChange.transformPosition(oldSelection.start), end: remoteChange.transformPosition(oldSelection.end), ); // Tell the editor to update the selection. // // It will still be collapsed and after the letter "f", but new // position will be 10 instead 6. _updateEditorSelection(newSelection) ;
  64. Scenario A: With selection transformation 😊😊😊 GIF: Right client has

    collapsed selection after “f”, left client adds 4 x’s at the start. Selection moves by one with each “x” added/removed before it.
  65. // We have selected every letter in the document "abcdef".

    const oldSelection = DocumentSelection (start: 0, end: 6); // Server notifies us that some other user inserts 4 x's // at the middle of the document, between "c" and "d". final remoteChange = Delta() ..retain(3) ..insert('xxxx'); // With the `.transformPosition()` utility method, we update our // selection to match the new document after composing `remoteChange`. final newSelection = DocumentSelection( start: remoteChange.transformPosition(oldSelection.start), end: remoteChange.transformPosition(oldSelection.end), ); // Tell the editor to update the selection. It will still contain the whole // document - start position will stay at 0, but end position will now be 6 // instead of 10. _updateEditorSelection(newSelection);
  66. // We have selected every letter in the document "abcdef".

    const oldSelection = DocumentSelection(start: 0, end: 6); // Server notifies us that some other user inserts 4 x's // at the middle of the document, between "c" and "d". final remoteChange = Delta() ..retain(3) ..insert('xxxx'); // With the `.transformPosition()` utility method, we update our // selection to match the new document after composing `remoteChange`. final newSelection = DocumentSelection( start: remoteChange.transformPosition(oldSelection.start), end: remoteChange.transformPosition(oldSelection.end), ); // Tell the editor to update the selection. It will still contain the whole // document - start position will stay at 0, but end position will now be 10 // instead of 6. _updateEditorSelection(newSelection) ;
  67. Scenario B: With selection transformation 😊😊😊 GIF: Right client has

    selected the entire document, left client adds 4 x’s in the middle. The start of the selection stays 0, end is shifted each time an “x” is added/removed.
  68. final history = SimpleLocalDocumentHistory(initialDocument: Delta()); history // Insert "abc", making

    the document into "abc". ..composeLocalChange(Delta()..insert('abc')) // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Delta()..retain(3)..delete(1) history.undo(); // Delta()..retain(3)..insert('X') history.redo();
  69. final history = SimpleLocalDocumentHistory(initialDocument: Delta()); history // Insert "abc", making

    the document into "abc". ..composeLocalChange(Delta()..insert('abc')) // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Delta()..retain(3)..delete(1) history.undo(); // Delta()..retain(3)..insert('X') history.redo();
  70. final history = SimpleLocalDocumentHistory(initialDocument: Delta()); history // Insert "abc", making

    the document into "abc". ..composeLocalChange(Delta()..insert('abc')) // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Delta()..retain(3)..delete(1) history.undo(); // Delta()..retain(3)..insert('X') history.redo();
  71. final history = SimpleLocalDocumentHistory(initialDocument: Delta()); history // Insert "abc", making

    the document into "abc". ..composeLocalChange(Delta()..insert('abc')) // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Delta()..retain(3)..delete(1) history.undo(); // Delta()..retain(3)..insert('X') history.redo();
  72. final history = SimpleLocalDocumentHistory(initialDocument: Delta()); history // Insert "abc", making

    the document into "abc". ..composeLocalChange(Delta()..insert('abc')) // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Delta()..retain(3)..delete(1) history.undo(); // Delta()..retain(3)..insert('X') history.redo();
  73. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({ required Delta initialDocument}) : _document =

    initialDocument ; final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  74. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[] ; final _redoStack = <Delta>[] ; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  75. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange (Delta change) { _undoStack.add(change.invert( _document)); _redoStack.clear(); _document = _document.compose(change) ; return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  76. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[] ; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange (Delta change) { _undoStack.add(change.invert( _document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  77. final document = Delta() ..insert('Hello world!\n'); final change = Delta()

    ..retain(6) ..insert('people') ..delete(5); // Delta() // ..retain(6) // ..insert('world') // ..delete(6); final invertedChange = change.invert(document);
  78. final document = Delta() ..insert('Hello world!\n'); final change = Delta()

    ..retain(6) ..insert('people') ..delete(5); // Delta() // ..retain(6) // ..insert('world') // ..delete(6); final invertedChange = change.invert(document);
  79. final document = Delta() ..insert('Hello world!\n'); final change = Delta()

    ..retain(6) ..insert('people') ..delete(5); // Delta() // ..retain(6) // ..insert('world') // ..delete(6); final invertedChange = change.invert(document);
  80. final document = Delta() ..insert('Hello world!\n'); final change = Delta()

    ..retain(6) ..insert('people') ..delete(5); // Delta() // ..retain(6) // ..insert('world') // ..delete(6); final invertedChange = change.invert(document);
  81. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[] ; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange (Delta change) { _undoStack.add(change.invert( _document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  82. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[] ; Delta _document; Delta composeLocalChange (Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  83. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange (Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change) ; return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  84. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast() ; _redoStack.add(change.invert( _document)); _document = _document.compose(change) ; return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  85. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[] ; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast() ; _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  86. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[] ; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert( _document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  87. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change) ; return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  88. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document ; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change) ; return _document; } Delta undo() { final change = _undoStack.removeLast() ; _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  89. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast() ; _undoStack.add(change.invert( _document)); _document = _document.compose(change) ; return change; } }
  90. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[] ; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast() ; _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  91. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[] ; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert( _document)); _document = _document.compose(change); return change; } }
  92. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast(); _undoStack.add(change.invert(_document)); _document = _document.compose(change) ; return change; } }
  93. class SimpleLocalDocumentHistory { SimpleLocalDocumentHistory({required Delta initialDocument}) : _document = initialDocument;

    final _undoStack = <Delta>[]; final _redoStack = <Delta>[]; Delta _document; Delta composeLocalChange(Delta change) { _undoStack.add(change.invert(_document)); _redoStack.clear(); _document = _document.compose(change); return _document; } Delta undo() { final change = _undoStack.removeLast(); _redoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } Delta redo() { final change = _redoStack.removeLast() ; _undoStack.add(change.invert(_document)); _document = _document.compose(change); return change; } }
  94. There’s one missing piece if this is supposed to work

    in multiplayer collaborative app…
  95. What if I have changes in my undo/redo history and

    some other user edits the document?
  96. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Other user deletes "a", making document into "bcX" // Delta()..retain(3)..delete(1) // OUTDATED: Will delete nothing history.undo(); // Delta()..retain(3)..insert('X') // OUTDATED: Will try to insert outside of document history.redo();
  97. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Other user deletes "a", making document into "bcX" // Delta()..retain(3)..delete(1) // OUTDATED: Will delete nothing history.undo(); // Delta()..retain(3)..insert('X') // OUTDATED: Will try to insert outside of document history.redo();
  98. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Other user deletes "a", making document into "bcX" // Delta()..retain(3)..delete(1) // OUTDATED: Will delete nothing history.undo(); // Delta()..retain(3)..insert('X') // OUTDATED: Will try to insert outside of document history.redo();
  99. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')); // Other user deletes "a", making document into "bcX" // Delta()..retain(3)..delete(1) // OUTDATED: Will delete nothing history.undo(); // Delta()..retain(3)..insert('X') // OUTDATED: Will try to insert outside of document history.redo();
  100. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')) // Other user deletes "a", making document into "bcX" ..composeRemoteChange( Delta()..delete(1)); // Delta()..retain(2)..delete(1) history.undo(); // Delta()..retain(2)..insert('X') history.redo();
  101. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')) // Other user deletes "a", making document into "bcX" ..composeRemoteChange( Delta()..delete(1)); // Delta()..retain(2)..delete(1) history.undo(); // Delta()..retain(2)..insert('X') history.redo();
  102. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')) // Other user deletes "a", making document into "bcX" ..composeRemoteChange( Delta()..delete(1)); // Delta()..retain(2)..delete(1) history.undo(); // Delta()..retain(2)..insert('X') history.redo();
  103. history // Insert "abc", making the document into "abc". ..composeLocalChange(Delta()..insert('abc'))

    // Insert "X" after "abc", making the document into "abcX". ..composeLocalChange(Delta()..retain(3)..insert('X')) // Other user deletes "a", making document into "bcX" ..composeRemoteChange( Delta()..delete(1)); // Delta()..retain(2)..delete(1) history.undo(); // Delta()..retain(2)..insert('X') history.redo();
  104. class SimpleLocalDocumentHistory { ... Delta composeRemoteChange (Delta delta) { _transformHistory(

    _undoStack, delta); _transformHistory( _redoStack, delta); _document = _document.compose(delta) ; return _document; } void _transformHistory (List<Delta> history , Delta delta) { var acc = delta ; for (var i = history. length - 1; i >= 0; i--) { final change = history[i] ; final transformedChange = acc.transform(change , true); acc = change.transform(acc , false); history[i] = transformedChange ; } } }
  105. Caveats • Unlimited history • Changes are super fine-grained •

    See the example project for a more realistic implementation
  106. class SuperEditorDocumentToQuillDeltaConverter { /// Converts a SuperEditor [Document] into a

    Quill [Delta] document. Delta editorDocumentToDelta(Document document); /// Converts a document [delta] to a SuperEditor [Document]. Document deltaToEditorDocument(Delta delta); } SuperEditor to Quill Deltas and back
  107. class SuperEditorDocumentSelectionTransformer { DocumentSelection transform({DocumentSelection oldSelection , Delta delta}) {

    // Convert SuperEditor Document positions to Quill positions (integers). // Then call delta.transformPosition() on them. // Finally, convert these integers to a new SuperEditor DocumentSelection // that reflects the new selection. } } SuperEditorDocumentSelectionTransformer
  108. final _converter = EditorDocumentToQuillDeltaConverter (); final _selectionTransformer = DocumentSelectionTransformer ();

    void handleRemoteDocumentChanged (Delta document , Delta change) { final newDocument = _converter.deltaToEditorDocument(document) ; _documentEditor .executeCommand( EditorCommandFunction ((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes( oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer .selection = _selectionTransformer .transform( oldDocument: oldDocument, newDocument: newDocument , oldSelection: _documentComposer .selection!, delta: change, ); }), ); }
  109. final _converter = EditorDocumentToQuillDeltaConverter (); final _selectionTransformer = DocumentSelectionTransformer ();

    void handleRemoteDocumentChanged(Delta document, Delta change) { final newDocument = _converter.deltaToEditorDocument(document); _documentEditor.executeCommand( EditorCommandFunction((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes(oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer.selection = _selectionTransformer.transform( oldDocument: oldDocument, newDocument: newDocument, oldSelection: _documentComposer.selection!, delta: change, ); }), ); }
  110. final _converter = EditorDocumentToQuillDeltaConverter(); final _selectionTransformer = DocumentSelectionTransformer(); void handleRemoteDocumentChanged

    (Delta document , Delta change) { final newDocument = _converter.deltaToEditorDocument(document); _documentEditor.executeCommand( EditorCommandFunction((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes(oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer.selection = _selectionTransformer.transform( oldDocument: oldDocument, newDocument: newDocument, oldSelection: _documentComposer.selection!, delta: change, ); }), ); }
  111. final _converter = EditorDocumentToQuillDeltaConverter (); final _selectionTransformer = DocumentSelectionTransformer(); void

    handleRemoteDocumentChanged (Delta document , Delta change) { final newDocument = _converter.deltaToEditorDocument(document) ; _documentEditor.executeCommand( EditorCommandFunction((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes(oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer.selection = _selectionTransformer.transform( oldDocument: oldDocument, newDocument: newDocument, oldSelection: _documentComposer.selection!, delta: change, ); }), ); }
  112. final _converter = EditorDocumentToQuillDeltaConverter(); final _selectionTransformer = DocumentSelectionTransformer(); void handleRemoteDocumentChanged

    (Delta document , Delta change) { final newDocument = _converter.deltaToEditorDocument(document) ; _documentEditor .executeCommand( EditorCommandFunction ((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes(oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer.selection = _selectionTransformer.transform( oldDocument: oldDocument, newDocument: newDocument, oldSelection: _documentComposer.selection!, delta: change, ); }), ); }
  113. final _converter = EditorDocumentToQuillDeltaConverter (); final _selectionTransformer = DocumentSelectionTransformer(); void

    handleRemoteDocumentChanged (Delta document , Delta change) { final newDocument = _converter.deltaToEditorDocument(document) ; _documentEditor.executeCommand( EditorCommandFunction((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes( oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer.selection = _selectionTransformer.transform( oldDocument: oldDocument, newDocument: newDocument, oldSelection: _documentComposer.selection!, delta: change, ); }), ); }
  114. final _converter = EditorDocumentToQuillDeltaConverter(); final _selectionTransformer = DocumentSelectionTransformer (); void

    handleRemoteDocumentChanged (Delta document , Delta change) { final newDocument = _converter.deltaToEditorDocument(document); _documentEditor.executeCommand( EditorCommandFunction((document, documentTransaction) { // Replace document contents with the nodes from the new document. _replaceDocumentNodes(oldDocument, newDocument); // Transform "my selection" so that my cursor stays after the same // character even though other users add content before it. _documentComposer .selection = _selectionTransformer .transform( oldDocument: oldDocument, newDocument: newDocument , oldSelection: _documentComposer .selection!, delta: change, ); }), ); }
  115. Challenges • Corrupted documents • Debugging and testing on two

    computers • Error logging - we need to obfuscate user documents - shuffle characters around randomly? ◦ Applying obfuscated changes should result in an obfuscated document
  116. Further optimizations • Grouping outgoing changes • Local persistence •

    Offline • MutableDeltaDocument instead of diffing?
  117. Thanks! • @SuperlistHQ on Twitter, superlist.com • I’m @koorankka on

    Twitter, blog at iiro.dev. Will publish slides & source code on Twitter. • github.com/roughike/super_editor_collaboration_sample for demo app • superlist.com/open-source • superlist.com/careers ◦ We’re looking for Flutter developers!