-
Notifications
You must be signed in to change notification settings - Fork 4.6k
Real-time collaboration: Use relative positions in undo stack #73066
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
Real-time collaboration: Use relative positions in undo stack #73066
Conversation
f03e09b to
c0881d8
Compare
fee51f8 to
33375d4
Compare
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
packages/sync/src/utils.ts
Outdated
| } | ||
| } | ||
|
|
||
| // Convenience types to manage block values with a clientId, attributes, and innerBlocks. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chriszarate I ended up copying over some of the convenience types from our plugin to implement findBlockByClientIdInDoc(). I know you had some recent type improvement changes for Y.Map, could those be useful here as well? Can we share them in a sane way?
chriszarate
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My initial question is: Why can’t this be handled by getChangesFrom / applyChangesToCRDTDoc (with some refactoring)? It’s very attractive to keep those functions in full control of the sync behavior. Splitting the logic brings complications and hidden dependencies between the packages.
Even though we don’t sync selection, could those mentioned functions read and mutate the selection in the way you’ve implemented here? Could applyChangesToCRDTDoc provide some selection hints that can be read by a naïve undo manager? I’m reading this PR quickly on mobile, so I’m sure I’m missing something.
513d4cc to
05e9a00
Compare
1492c28 to
e467342
Compare
faf2b2e to
4b30e72
Compare
e467342 to
5b61a4e
Compare
de365b7 to
f7977ca
Compare
d90bd82 to
df407f5
Compare
f7977ca to
f0653fc
Compare
… loop before restoring selection
- Remove count from getSelectionHistory(), replace existing calls - Remove use of "position" and replace with "selection" in comments - Update incorrect comments
| } | ||
|
|
||
| /** | ||
| * This class is used to track recent block selections to help in restoring |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| * This class is used to track recent block selections to help in restoring | |
| * This function is used to track recent block selections to help in restoring |
| /** | ||
| * Get the current selection (most recent selection in the current block). | ||
| */ | ||
| const getCurrentSelection = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would you mind adding return type annotations to all functions in this file? Most are covered by the BlockSelectionHistory type, but I think it's always good practice to avoid inferred return types. It helps prevent unintentional changes to the return type, which can lead to type bugs in consuming code.
chriszarate
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a few (hopefully) simplifying commits. This looks great!
What?
Improve undo positioning in real-time collaborative documents.
Our current implementation of the
UndoStackwith real-time collaboration undoes stack operations but does not manage the user's cursor position within the undo stack. This leads to a lot of unexpected jumps, selection loss, or relocation to the beginning of the line when changes are undone.Undo in the current
wpvip/rtc-pluginbranch:Screen.Recording.2025-11-06.at.2.37.15.PM.mov
The user's cursor can jump to an unexpected place or lose focus when an undo operation happens. This is caused from restoring content but not the user's cursor.
The changes in this PR use
Y.UndoManager'sstack-item-addedandstack-item-poppedevents to store the user'sRelativePositionon the stack. This fixes regular undo behavior, and also accounts for position changes that might have been made by another user in the CRDT doc. Here are the same tests above in this branch:Undo in this PR (
fix/relative-position-undo):Screen.Recording.2025-11-06.at.2.34.12.PM.mov
Why?
Because our real-time undo stack relies on
Y.UndoManager, we need to give it information on the user's cursor when we save items to the stack, or change the user's cursor when we pop items off. We storeRelativePositionobjects instead of absolute offsets which allow cursor positions to move if the underlying content has changed.How?
This PR relies on a couple of important pieces:
Viewing the user's current selection, and restoring selection in the
sync'sUndoManageris difficult without introducing circular dependencies. To work around this, we passaddUndoMeta()andrestoreUndoMeta()record handlers to core data, which the sync manager uses to pass undo stack meta and activate it during an undo operation.The
BlockSelectionHistorylives in core data class keeps a recent set of selected blocks, bucketed by start and end block clientId, which was found in practice to be a good way to store recent selections without duplicated data. For example, here's a user typing in two blocks:The de-duplicated selection updates from the block editor store give these set of selection updates:
<block1>and<block2>are shortened block clientIds.There's a lot of noise in here, especially when blocks are created like
<block2>which send an update with just theclientId, then theclientIdandattributeKey, and finally the location of the cursor when the block is done being created with anoffset. It can be tricky to determine which of these represent a valid state for the user's selection to return to.BlockSelectionHistoryprocesses these updates to bucket them byclientId. At the end of the actions above, here's whatBlockSelectionHistoryhas stored:where the
relativePositionproperty is aY.RelativePositiontype.Now when we process an undo stack being added, it's easy to grab the current relative position and some recent backups and push them onto the stack. The current implementation saves the current selection along with the last three unique block selections made before it.
When the user undoes an item in the undo stack, we iterate through the positions on that item and move to the first valid one. In testing, especially in a multi-user environment, that was found to be a robust way to handle fallback positions when the underlying document can change.
Selection ranges
We also store the
startandendfor each selection, which allows us to restore selected content made before a change:This is change from non-collaborative Gutenberg, which does not keep selection state in the undo stack.
What's missing?
There are still some features we can enhance to improve the undo stack or match WordPress behavior better:
1. Match WordPress behavior by creating an undo stack item when a block is inserted
WordPress will create a new undo location at the beginning of each block:
When undoing, the cursor stops at each new block. However,
Y.UndoManagerinstead uses acaptureTimeoutof500msto decide when new stack items are added. If typed quickly enough, all 3 blocks are grouped together in one stack item instead:This may be possible to fix, but in the current behavior
Y.UndoManageris fully in charge of when stack items are committed and we don't have specific say other thancaptureTimeout.2. Block lookup inefficiencies
In order to convert a cursor offset to a
RelativePosition, we need access to the underlyingY.Textobject that represents the block's RichText content. This currently relies on thefindBlockByClientIdInDoc()function, which recursively scans a document's entire content to find the associatedY.Textobject. This works fine for small document tests but may be a bottleneck in larger documents.This is the same inefficient approach we use within the vip-real-time-collaboration plugin, but it should be changed in both to a better system. I think a top level map of
clientId -> attributeName -> Y.Textwould be a much more efficient way to storeY.Textvalues for lookup and would solve the O(N) runtime of the current implementation, but this will require changes to attribute storage.Testing Instructions
RelativePosition, the user's cursor should still return to the correct logical spot in a previous paragraph even if the offset has changed.There are also a set of tests in
block-selection-history.test.ts: