Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 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
25 changes: 25 additions & 0 deletions packages/core-data/src/entities.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { capitalCase, pascalCase } from 'change-case';
import apiFetch from '@wordpress/api-fetch';
import { __unstableSerializeAndClean, parse } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
import { generateUserInfo } from '@wordpress/sync';

/**
* Internal dependencies
Expand All @@ -18,6 +19,7 @@ import {
applyPostChangesToCRDTDoc,
getPostChangesFromCRDTDoc,
} from './utils/crdt';
import { PostEditorAwareness } from './post-editor-awareness';

export const DEFAULT_ENTITY_KEY = 'id';
const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ];
Expand Down Expand Up @@ -381,6 +383,29 @@ async function loadPostTypeEntities() {
supports: {
crdtPersistence: true,
},

createAwareness: ( ydoc, recordHandlers, currentUser ) => {
const awareness = new PostEditorAwareness( ydoc );

const states = awareness.getStates();
const otherUserColors = Array.from( states.entries() )
.filter(
( [ clientId, state ] ) =>
state.userInfo &&
clientId !== awareness.clientID
)
.map( ( [ , state ] ) => state.userInfo.color )
.filter( Boolean );

const userInfo = generateUserInfo(
currentUser,
otherUserColors
);

awareness.setUp( recordHandlers, userInfo );

return awareness;
},
};
}

Expand Down
157 changes: 157 additions & 0 deletions packages/core-data/src/post-editor-awareness.ts
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 );

@ingeniumed ingeniumed Jan 22, 2026

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.

This might look weird, but it's due to the fact that we need access to the right name, kind, and id. This is the best way to get access to that, given we are in core-data. The other option is to keep passing in these parameters around. As a result, I figured this'd be better as it ties the entity's parameters to the awareness instance.

@chriszarate - if you have a better way that avoids this let me know. TBH, the methods in the recordHandlers are available in core-data already which is why I kept it this way.

}

/**
* 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 );
}
}
8 changes: 6 additions & 2 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -192,7 +194,7 @@ export const getEntityRecord =
recordWithTransients,
{
// Handle edits sourced from the sync manager.
editRecord: ( edits ) => {
editRecord: ( edits, options = {} ) => {

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.

This is to allow for core-data and editor to not be used within selection-utils.ts from the sync package. It also means that the awareness instance is always aware about what post type and post ID it's meant for. There's no need to fetch it and verify if its set or not.

if ( ! Object.keys( edits ).length ) {
return;
}
Expand All @@ -206,6 +208,7 @@ export const getEntityRecord =
meta: {
undo: undefined,
},
options,
} );
},
// Get the current entity record (with edits)
Expand All @@ -232,7 +235,8 @@ export const getEntityRecord =
key
);
},
}
},
currentUser
);
}
}
Expand Down
106 changes: 106 additions & 0 deletions packages/core-data/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import type { Y, BaseState } from '@wordpress/sync';

export interface AnyFunction {
( ...args: any[] ): any;
}
Expand All @@ -13,3 +18,104 @@ export interface WPSelection {
selectionEnd: WPBlockSelection;
selectionStart: WPBlockSelection;
}

/**
* Convenience types to manage block values with a clientId, attributes, and innerBlocks.
*/
export type BlockInnerBlocks = Y.Array< SelectableBlock >;
type BlockClientId = string;
type BlockAttributes = Y.Map< Y.Text >;

/**
* A block that can be selected.
*/
export type SelectableBlock = Y.Map<
BlockClientId | BlockAttributes | BlockInnerBlocks
>;

/**
* The type of selection.
*/
export enum SelectionType {
None = 'none',
Cursor = 'cursor',
SelectionInOneBlock = 'selection-in-one-block',
SelectionInMultipleBlocks = 'selection-in-multiple-blocks',
WholeBlock = 'whole-block',
}

/**
* The position of the cursor.
*/
export type CursorPosition = {
relativePosition: Y.RelativePosition;

// Also store the absolute offset index of the cursor from the perspective
// of the user who is updating the selection.
//
// Do not use this value directly, instead use `createAbsolutePositionFromRelativePosition()`
// on relativePosition for the most up-to-date positioning.
//
// This is used because local Y.Text changes (e.g. adding or deleting a character)
// can result in the same relative position if it is pinned to an unchanged
// character. With both of these values as editor state, a change in perceived
// position will always result in a redraw.
absoluteOffset: number;
};

export type SelectionNone = {
// The user has not made a selection.
type: SelectionType.None;
};

export type SelectionCursor = {
// The user has a cursor position in a block with no text highlighted.
type: SelectionType.Cursor;
blockId: string;
cursorPosition: CursorPosition;
};

export type SelectionInOneBlock = {
// The user has highlighted text in a single block.
type: SelectionType.SelectionInOneBlock;
blockId: string;
cursorStartPosition: CursorPosition;
cursorEndPosition: CursorPosition;
};

export type SelectionInMultipleBlocks = {
// The user has highlighted text over multiple blocks.
type: SelectionType.SelectionInMultipleBlocks;
blockStartId: string;
blockEndId: string;
cursorStartPosition: CursorPosition;
cursorEndPosition: CursorPosition;
};

export type SelectionWholeBlock = {
// The user has a non-text block selected, like an image block.
type: SelectionType.WholeBlock;
blockId: string;
};

export type SelectionState =
| SelectionNone
| SelectionCursor
| SelectionInOneBlock
| SelectionInMultipleBlocks
| SelectionWholeBlock;

/**
* 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