Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
816e55c
Being adding user info
ingeniumed Jan 15, 2026
f81ed74
Got the user fetch working
ingeniumed Jan 16, 2026
6e3eb80
Added in some test logging
ingeniumed Jan 16, 2026
2836b7e
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 16, 2026
cf05627
Attempting to fix the sync
ingeniumed Jan 16, 2026
2c1d5cf
Add a comment explining the bug
ingeniumed Jan 18, 2026
8772cb2
Add support for selection
ingeniumed Jan 19, 2026
979e04a
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 19, 2026
e68cfde
Ensure the pckakge-lock changes are in
ingeniumed Jan 19, 2026
77cad8f
Add more comments and simplify the user handling
ingeniumed Jan 19, 2026
0e12107
Tweak the exported functions
ingeniumed Jan 19, 2026
1b19ef1
Revert the webpack workaround
ingeniumed Jan 19, 2026
215942c
Fix the type error
ingeniumed Jan 19, 2026
4f37149
ignore types for block editor import
ingeniumed Jan 19, 2026
e9cc4bc
Fix the typo in the constant
ingeniumed Jan 19, 2026
63a11d7
Tweaked the local storage key
ingeniumed Jan 19, 2026
71c7741
Attempting to solve the test failures
ingeniumed Jan 20, 2026
f6a7cc0
Fix the test fialures
ingeniumed Jan 20, 2026
45c439d
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 20, 2026
9200272
Remove a TODO
ingeniumed Jan 20, 2026
a2f7a09
Re-wrote the user selection to be in the core-data, and move the awar…
ingeniumed Jan 21, 2026
d1066e9
Clean up the code
ingeniumed Jan 21, 2026
96c6e6a
Added a todo for local storage
ingeniumed Jan 21, 2026
40bba56
Remove the block-editor fix
ingeniumed Jan 21, 2026
9fb1542
Fix the test using STORE_NAME
ingeniumed Jan 21, 2026
266fd2c
Move awareness implementation details to core-data, and only leave th…
ingeniumed Jan 22, 2026
2d6ca82
Replace undefined awareness test with a mock
ingeniumed Jan 22, 2026
335f32f
Fix the tests failures in resolvers
ingeniumed Jan 22, 2026
4e6bff0
Merge branch 'trunk' of github.com:WordPress/gutenberg into add/user-…
ingeniumed Jan 22, 2026
731eea5
Improve types and keep WordPress domain knowledge out of sync package…
chriszarate Jan 22, 2026
e8af246
Remove unnecessary exports
chriszarate Jan 22, 2026
daa079a
Rename getAwarenessInstance => getAwareness for symmetry
chriszarate Jan 22, 2026
fa964ed
Remove vestial userInfo reference
chriszarate Jan 22, 2026
ec820f4
Relocate selection types and use YMapWrap
chriszarate Jan 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20.17.10",

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Member

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

"deep-freeze": "0.0.1"
},
"peerDependencies": {
Expand Down
9 changes: 9 additions & 0 deletions packages/core-data/src/awareness/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Delay in milliseconds before throttling the cursor position updates.
*/
export const AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS = 100;

/**
* Delay in milliseconds before updating the cursor position.
*/
export const LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS = 5;
187 changes: 187 additions & 0 deletions packages/core-data/src/awareness/post-editor-awareness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* WordPress dependencies
*/
import { dispatch, select, subscribe } from '@wordpress/data';
import { AwarenessState, type Y } from '@wordpress/sync';
// @ts-ignore No exported types for block editor store selectors.
import { store as blockEditorStore } from '@wordpress/block-editor';

/**
* Internal dependencies
*/
import {
AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS,
LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS,
} from './config';
import { STORE_NAME as coreStore } from '../name';
import { generateUserInfo, areUserInfosEqual } from './utils';
import {
areSelectionsStatesEqual,
getSelectionState,
} from '../utils/crdt-user-selections';

import type { WPBlockSelection } from '../types';
import type { EditorState, PostEditorState } from './types';

export class PostEditorAwareness extends AwarenessState< PostEditorState > {
protected equalityFieldChecks = {
editorState: this.areEditorStatesEqual,
userInfo: areUserInfosEqual,
};

public constructor(
doc: Y.Doc,
private kind: string,
private name: string,
private postId: number
) {
super( doc );
}

public setUp(): void {
super.setUp();

this.setCurrentUserInfo();
this.subscribeToUserSelectionChanges();
}

/**
* Set the current user info in the local state.
*/
private setCurrentUserInfo(): void {
const states = this.getStates();
const otherUserColors = Array.from( states.entries() )
.filter(
( [ clientId, state ] ) =>
state.userInfo && clientId !== this.clientID
)
.map( ( [ , state ] ) => state.userInfo.color )
.filter( Boolean );

// Get current user info and set it in local state.
const currentUser = select( coreStore ).getCurrentUser();
const userInfo = generateUserInfo( currentUser, otherUserColors );
this.setLocalStateField( 'userInfo', userInfo );
}

/**
* Subscribe to user selection changes and update the selection state.
*/
private subscribeToUserSelectionChanges(): 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(
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 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(
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,
};

// @ts-ignore Types are not provided when using store name instead of store instance.
dispatch( coreStore ).editEntityRecord(
this.kind,
this.name,
this.postId,
edits,
options
);
}

/**
* 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 );
}
}
38 changes: 38 additions & 0 deletions packages/core-data/src/awareness/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Internal dependencies
*/
import type { SelectionState } from '../utils/crdt-user-selections';
import type { User } from '../entity-types';

export type UserInfo = Pick<
User< 'view' >,
'id' | 'name' | 'slug' | 'avatar_urls'
> & {
browserType: string;
color: string;
enteredAt: number;
};

/**
* This base state represents the presence of the user. We expect it to be
* extended to include additional state describing the user's current activity.
* This state must be serializable and compact.
*/
export interface BaseState {
userInfo: UserInfo;
}

/**
* The editor state includes information about the user's current selection.
*/
export interface EditorState {
selection: SelectionState;
}

/**
* The post editor state extends the base state with information used to render
* presence indicators in the post editor.
*/
export interface PostEditorState extends BaseState {
editorState?: EditorState;
}
Loading
Loading