Real-time collaboration: Use alternative diff in quill-delta, provide incremental text updates #73699
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
What?
Brings
Delta/difflibrary updates totrunk. See original PR forwpvip/rtc-pluginin #72604.This PR:
quill-deltato the sync package.fast-difflibrary used inquill-delta, which is incompatible with GPLv2 due to the Apache 2 license. Instead we usediff, which has a compatible license.fast-diffcalleddiffWithCursor()that attempts to match insert/deletion location with user cursor position. More information on this below.Why?
For multi-user editing within a single rich text area, we want to send incremental updates instead of entirely new strings in Yjs. This makes update operations more efficient and allows us to support relative positioning.
Y.Textnatively supports applying changes asDeltas along withquill-delta, but we're unable to directly use thequill-deltalibrary due to license constraints.What does the new
diffWithCursor()function do? The per-character function provided bydiffisdiffChars(), which takes two strings as input but does not accept a cursor position. This can cause ambiguous changes when there are repeated characters or substrings.For example, a user enters an
ain the middle of a run of the same character:diffChars( 'aaa', 'aaaa' )gives this result:This indicates adding an
ato the end of the string. BecausediffChars()only accepts two strings and has no cursor awareness, it can only guess where theawas added, and always selects the last position. This results in cursors "moving backwards" when another user types:The "Chrome" user's cursor should move forward when my user is typing, but it doesn't because the
ais being appended to the end of the string and the relative positioning of cursor fails to match user expectations.The new
diffWithCursor()function wraps the initialdiffresult and attempts to move deletions or insertions to the user's cursor position. Here's what the processed result ofdiffChars()looks like:This matches the actual user's experience and where the character is added. The result, with the character in the right position, is relative postioning working as expected:
How?
Here's a runthrough of the
diffWithCursor()function. I'm not sure it handles every case, but it handles a lot of cases better thandiffalone.First, we convert incoming
Deltaoperations to strings we can use withdiffChars().For example, if we have the string
jajajaand a user has pastedjaat cursor position 2, (ja|jaja->jaja|jajawhere|is the cursor),this.opswill be{ insert: "jajaja" }andother.opswill be{ insert: "jajajaja" }.deltasToStrings()will process these operations into two strings,"jajaja"and"jajajaja".Next,
diffChars()is run against those two strings which is provided by thediffpackage. It gives this result:The
jaaddition has incorrectly been placed at the end of the diff.
Next, we post-process this diff to see if we can move the changes to the position of the cursor. Each diff object is iterated with these rules:
Path 1: If the current segment is unchanged, but we see the cursor ended in this segment, we try to see if we can move the insertion from the next segment.
For the example above, we encounter this path on the first diff segment.
cursorAfterChangeis4, indicating the cursor ended in the middle of the firstjajajasegment, but there is no insertion there. There is also an insertion directly after this segment, so we calltryMoveInsertionToCursor()which will check if the insertion after this segment ({count:2, added:true, value:"ja"}) can be moved to match the cursor location. If it can, then it'll:{count:2, value:"ja"}{count:2, added:true, value:"ja"}{count:2, value:"jaja"}with this result:This segment and the next is then consumed.
Path 2: Works similarly to part 1, but checking for deletions. Here's an example for the text
aaaawhere anais deleted in the second position (aa|aa->a|aa):We process the original diff of:
On processing the second segment, we see a deletion where the cursor was in the previous segment. Calling into
tryMoveDeletionToCursor()will test if the deletion string can be moved to the prior segment. If this is possible, we split the prior segment and add the deletion with this result:If the segment doesn't match either case above, add it as-is to the processed segments.
Lastly we convert the result of the processed diffs to
Deltaformat withconvertChangesToDelta(). For example:Before processing:
After processing as a
Delta:This indicates that the original text stays the same for the first two characters, and adds
ja, matching our original user behavior.Testing Instructions
aaaaaorjajajajaand insert multiple characters with pastes and ensure the second user's cursor in awareness moves as expected.The 34 new
diffWithCursor()tests can be run via: