-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Real-time Collaboration: Add user and selection information to awareness #74728
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
Changes from 27 commits
816e55c
f81ed74
6e3eb80
2836b7e
cf05627
2c1d5cf
8772cb2
979e04a
e68cfde
77cad8f
0e12107
1b19ef1
215942c
4f37149
e9cc4bc
63a11d7
71c7741
f6a7cc0
45c439d
9200272
a2f7a09
d1066e9
96c6e6a
40bba56
9fb1542
266fd2c
2d6ca82
335f32f
4e6bff0
731eea5
e8af246
daa079a
fa964ed
ec820f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| /** | ||
| * WordPress dependencies | ||
| */ | ||
| import { select, subscribe } from '@wordpress/data'; | ||
| import { | ||
| LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, | ||
| AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS, | ||
| type RecordHandlers, | ||
| AwarenessState, | ||
| type UserInfo, | ||
| areUserInfosEqual, | ||
| } from '@wordpress/sync'; | ||
| // @ts-ignore No exported types for block editor store selectors. | ||
| import { store as blockEditorStore } from '@wordpress/block-editor'; | ||
|
|
||
| /** | ||
| * Internal dependencies | ||
| */ | ||
| import { | ||
| areSelectionsStatesEqual, | ||
| getSelectionState, | ||
| } from './utils/crdt-user-selections'; | ||
| import type { WPBlockSelection, PostEditorState, EditorState } from './types'; | ||
|
|
||
| export class PostEditorAwareness extends AwarenessState< PostEditorState > { | ||
| protected equalityFieldChecks = { | ||
| editorState: this.areEditorStatesEqual, | ||
| userInfo: areUserInfosEqual, | ||
| }; | ||
|
|
||
| public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { | ||
| super.setUp( recordHandlers, userInfo ); | ||
|
|
||
| this.subscribeToUserSelectionChanges( recordHandlers ); | ||
| } | ||
|
|
||
| /** | ||
| * Subscribe to user selection changes and update the selection state. | ||
| * | ||
| * @param recordHandlers - The record handlers. | ||
| */ | ||
| private subscribeToUserSelectionChanges( | ||
| recordHandlers: RecordHandlers | ||
| ): void { | ||
| const { | ||
| getSelectionStart, | ||
| getSelectionEnd, | ||
| getSelectedBlocksInitialCaretPosition, | ||
| } = select( blockEditorStore ); | ||
|
|
||
| // Keep track of the current selection in the outer scope so we can compare | ||
| // in the subscription. | ||
| let selectionStart = getSelectionStart(); | ||
| let selectionEnd = getSelectionEnd(); | ||
| let localCursorTimeout: NodeJS.Timeout | null = null; | ||
|
|
||
| subscribe( () => { | ||
| const newSelectionStart = getSelectionStart(); | ||
| const newSelectionEnd = getSelectionEnd(); | ||
|
|
||
| if ( | ||
| newSelectionStart === selectionStart && | ||
| newSelectionEnd === selectionEnd | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| selectionStart = newSelectionStart; | ||
| selectionEnd = newSelectionEnd; | ||
|
|
||
| // Typically selection position is only persisted after typing in a block, which | ||
| // can cause selection position to be reset by other users making block updates. | ||
| // Ensure we update the controlled selection right away, persisting our cursor position locally. | ||
| const initialPosition = getSelectedBlocksInitialCaretPosition(); | ||
| void this.updateSelectionInEntityRecord( | ||
| recordHandlers, | ||
| selectionStart, | ||
| selectionEnd, | ||
| initialPosition | ||
| ); | ||
|
|
||
| // We receive two selection changes in quick succession | ||
| // from local selection events: | ||
| // { clientId: "123...", attributeKey: "content", offset: undefined } | ||
| // { clientId: "123...", attributeKey: "content", offset: 554 } | ||
| // Add a short debounce to avoid sending the first selection change. | ||
| if ( localCursorTimeout ) { | ||
| clearTimeout( localCursorTimeout ); | ||
| } | ||
|
|
||
| localCursorTimeout = setTimeout( () => { | ||
| const selectionState = getSelectionState( | ||
| selectionStart, | ||
| selectionEnd, | ||
| this.doc | ||
| ); | ||
|
|
||
| this.setThrottledLocalStateField( | ||
| 'editorState', | ||
| { selection: selectionState }, | ||
| AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS | ||
| ); | ||
| }, LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS ); | ||
| } ); | ||
| } | ||
|
|
||
| /** | ||
| * Update the entity record with the current user's selection. | ||
| * | ||
| * @param recordHandlers | ||
| * @param selectionStart - The start position of the selection. | ||
| * @param selectionEnd - The end position of the selection. | ||
| * @param initialPosition - The initial position of the selection. | ||
| */ | ||
| private async updateSelectionInEntityRecord( | ||
| recordHandlers: RecordHandlers, | ||
| selectionStart: WPBlockSelection, | ||
| selectionEnd: WPBlockSelection, | ||
| initialPosition: number | null | ||
| ): Promise< void > { | ||
| // Send an entityRecord `selection` update if we have a selection. | ||
| // | ||
| // Normally WordPress updates the `selection` property of the post when changes are made to blocks. | ||
| // In a multi-user setup, block changes can occur from other users. When an entity is updated from another | ||
| // user's changes, useBlockSync() in Gutenberg will reset the user's selection to the last saved selection. | ||
| // | ||
| // Manually adding an edit for each movement ensures that other user's changes to the document will | ||
| // not cause the local user's selection to reset to the last local change location. | ||
| const edits = { | ||
| selection: { selectionStart, selectionEnd, initialPosition }, | ||
| }; | ||
|
|
||
| const options = { | ||
| undoIgnore: true, | ||
| }; | ||
|
|
||
| recordHandlers.editRecord( edits, options ); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This might look weird, but it's due to the fact that we need access to the right @chriszarate - if you have a better way that avoids this let me know. TBH, the methods in the |
||
| } | ||
|
|
||
| /** | ||
| * Check if two editor states are equal. | ||
| * | ||
| * @param state1 - The first editor state. | ||
| * @param state2 - The second editor state. | ||
| * @return True if the editor states are equal, false otherwise. | ||
| */ | ||
| private areEditorStatesEqual( | ||
| state1?: EditorState, | ||
| state2?: EditorState | ||
| ): boolean { | ||
| if ( ! state1 || ! state2 ) { | ||
| return state1 === state2; | ||
| } | ||
|
|
||
| return areSelectionsStatesEqual( state1.selection, state2.selection ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -184,6 +184,8 @@ export const getEntityRecord = | |
| transientConfig.read( recordWithTransients ); | ||
| } ); | ||
|
|
||
| const currentUser = await resolveSelect.getCurrentUser(); | ||
|
|
||
| // Load the entity record for syncing. | ||
| await getSyncManager()?.load( | ||
| entityConfig.syncConfig, | ||
|
|
@@ -192,7 +194,7 @@ export const getEntityRecord = | |
| recordWithTransients, | ||
| { | ||
| // Handle edits sourced from the sync manager. | ||
| editRecord: ( edits ) => { | ||
| editRecord: ( edits, options = {} ) => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is to allow for |
||
| if ( ! Object.keys( edits ).length ) { | ||
| return; | ||
| } | ||
|
|
@@ -206,6 +208,7 @@ export const getEntityRecord = | |
| meta: { | ||
| undo: undefined, | ||
| }, | ||
| options, | ||
| } ); | ||
| }, | ||
| // Get the current entity record (with edits) | ||
|
|
@@ -232,7 +235,8 @@ export const getEntityRecord = | |
| key | ||
| ); | ||
| }, | ||
| } | ||
| }, | ||
| currentUser | ||
| ); | ||
| } | ||
| } | ||
|
|
||
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.
Noticed that the NodeJs.timeout was giving me errors and realized this wasn't there.
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.
It's better to use the same version of this package (
@types/node) as used in other places in the monorepo. I have fixed it in #74950