From 816e55cfbc7fa2d8b4be291eeb5315ddf374c0a6 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Fri, 16 Jan 2026 08:09:30 +1100 Subject: [PATCH 01/30] Being adding user info --- .../lib/util.js | 1 - packages/sync/package.json | 1 + .../sync/src/awareness/awareness-manager.ts | 24 +++++++- .../sync/src/awareness/awareness-state.ts | 55 +++++++++++-------- .../sync/src/awareness/awareness-types.ts | 29 +++++++++- .../awareness/post-editor-awareness-state.ts | 14 ++++- packages/sync/src/index.ts | 3 + packages/sync/src/utils.ts | 47 ++++++++++++++++ 8 files changed, 145 insertions(+), 29 deletions(-) diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index 5a5d88b8006ff6..ce420116404a29 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -5,7 +5,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', diff --git a/packages/sync/package.json b/packages/sync/package.json index 4d6500f1d80098..92c13425be0673 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -30,6 +30,7 @@ "*.md" ], "main": "build/index.cjs", + "wpScript": true, "module": "build-module/index.mjs", "exports": { ".": { diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 6e814ca9dd9429..7c5e56778f59d6 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -3,12 +3,19 @@ */ import type * as Y from 'yjs'; +/** + * WordPress dependencies + */ +import { select } from '@wordpress/data'; + /** * Internal dependencies */ import type { ObjectID, ObjectType } from '../types'; import type { AwarenessState } from './awareness-state'; import { PostEditorAwarenessState } from './post-editor-awareness-state'; +import { UserInfo, WordPressUserInfo } from './awareness-types'; +import { getBrowserName } from '../utils'; const awarenessInstances: Map< string, AwarenessState > = new Map(); @@ -26,6 +33,14 @@ function getAwarenessInstance( return awarenessInstances.get( getAwarenessId( objectType, objectId ) ); } +function getUserInfo( wpUser: WordPressUserInfo ): UserInfo { + return { + ...wpUser, + browserType: getBrowserName(), + enteredAt: Date.now(), + }; +} + /** * Get the post editor awareness instance for the given post ID and post type. * @param postId Post ID. @@ -61,7 +76,14 @@ export async function createAwareness( ): Promise< AwarenessState | undefined > { if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); - awareness.setUp(); + + const currentUser = select( 'core/data' ).getCurrentUser(); + + console.log( 'currentUser', currentUser ); + const userInfo = getUserInfo( currentUser ); + console.log( 'userInfo', userInfo ); + + awareness.setUp( userInfo ); awarenessInstances.set( getAwarenessId( objectType, objectId ), awareness diff --git a/packages/sync/src/awareness/awareness-state.ts b/packages/sync/src/awareness/awareness-state.ts index 899d8091a0d4b3..1faa887426a40e 100644 --- a/packages/sync/src/awareness/awareness-state.ts +++ b/packages/sync/src/awareness/awareness-state.ts @@ -3,6 +3,7 @@ */ import { TypedAwareness, + UserInfo, type BaseState, type EnhancedState, type EqualityFieldCheck, @@ -150,7 +151,9 @@ export abstract class AwarenessState< /** * Set up. */ - public setUp(): void { + public setUp( userInfo: UserInfo): void { + this.setLocalStateField( 'userInfo', userInfo ); + this.on( 'change', ( { added, removed, updated }: AwarenessStateChange ) => { @@ -228,27 +231,35 @@ export abstract class AwarenessState< ] ); const updatedStates = new Map< number, EnhancedState< State > >( - [ ...this.disconnectedUsers, ...states.keys() ].map( - ( clientId ) => { - const rawState: State = this.seenStates.get( clientId )!; - - const isConnected = - ! this.disconnectedUsers.has( clientId ); - const isMe = clientId === this.clientID; - const myState: Partial< State > = isMe - ? this.myThrottledState - : {}; - const state: EnhancedState< State > = { - ...rawState, - ...myState, - clientId, - isConnected, - isMe, - }; - - return [ clientId, state ]; - } - ) + [ ...this.disconnectedUsers, ...states.keys() ] + .filter( clientId => { + // Exclude any users without `userInfo`. + // This can happen from the Yjs inspector, which joins the awareness + // state without providing user data. + return Boolean( this.seenStates.get( clientId )?.userInfo ); + } ) + .map( + ( clientId ) => { + // The filter above ensures that seenStates has the clientId. + const rawState: State = this.seenStates.get( clientId )!; + + const isConnected = + ! this.disconnectedUsers.has( clientId ); + const isMe = clientId === this.clientID; + const myState: Partial< State > = isMe + ? this.myThrottledState + : {}; + const state: EnhancedState< State > = { + ...rawState, + ...myState, + clientId, + isConnected, + isMe, + }; + + return [ clientId, state ]; + } + ) ); if ( ! forceUpdate ) { diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index c786f1d0c54d86..0d373a606c6ebf 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -37,14 +37,37 @@ export class TypedAwareness< State extends BaseState > extends Awareness { } } +/** + * This base user info is a subset of the User interface from @wordpress/core-data. + * + * In order to avoid circular dependencies, we define it here instead of importing + * the User interface from @wordpress/core-data. + * + * The avatarUrl is an additional field that is not part of the User interface. + */ +export interface WordPressUserInfo { + id: number; + name: string; + avatarUrl?: string; +} + +/** + * The user info interface extends the base user info with additional fields used for presence + * indicators. + */ +export interface UserInfo extends WordPressUserInfo { + browserType: 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. - * - * TODO: Add in the user information. */ -export interface BaseState {} +export interface BaseState { + userInfo: UserInfo; +} /** * An enhanced state includes additional metadata about the user's connection diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index 2b1c3518a8aa6d..0a36bbc9af569c 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -1,8 +1,18 @@ -import type { PostEditorState } from './awareness-types'; +/** + * Internal dependencies + */ +import type { PostEditorState, UserInfo } from './awareness-types'; import { AwarenessState } from './awareness-state'; +import { areUserInfosEqual } from '../utils'; export class PostEditorAwarenessState extends AwarenessState< PostEditorState > { - protected equalityFieldChecks = {}; + protected equalityFieldChecks = { + userInfo: areUserInfosEqual, + }; + + public setUp( userInfo: UserInfo ): void { + super.setUp( userInfo ); + } // TODO: Add in subscription for user selection changes. } diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 1aa27a30724100..d17ec2dbc2fa18 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -25,3 +25,6 @@ export { } from './config'; export { createSyncManager } from './manager'; export type * from './types'; +export { + setConnectionStatus, +} from './awareness/awareness-manager'; diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index fcd77dc6db2661..7b4d6f7e61de9e 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -14,6 +14,7 @@ import { CRDT_STATE_VERSION_KEY, } from './config'; import type { CRDTDoc } from './types'; +import type { UserInfo } from './awareness/awareness-types'; // An object representation of CRDT document metadata. type DocumentMeta = Record< string, DocumentMetaValue >; @@ -105,3 +106,49 @@ export function areMapsEqual< Key, Value >( return true; } + +/** + * Compare two user infos for equality. + * @param userInfo1 - The first user info to compare. + * @param userInfo2 - The second user info to compare. + * @returns True if the user infos are equal, false otherwise. + */ +export function areUserInfosEqual( userInfo1?: UserInfo, userInfo2?: UserInfo ): boolean { + if ( ! userInfo1 || ! userInfo2 ) { + return userInfo1 === userInfo2; + } + + if ( Object.keys( userInfo1 ).length !== Object.keys( userInfo2 ).length ) { + return false; + } + + return Object.entries( userInfo1 ).every( ( [ key, value ] ) => { + // Update this function with any non-primitive fields added to UserInfo. + return value === userInfo2[ key as keyof UserInfo ]; + } ); +} + +/** + * Get the browser name from the user agent. + * @returns The browser name. + */ +export function getBrowserName(): string { + const userAgent = window.navigator.userAgent; + let browserName = 'Unknown'; + + if ( userAgent.includes( 'Firefox' ) ) { + browserName = 'Firefox'; + } else if ( userAgent.includes( 'Edg' ) ) { + browserName = 'Microsoft Edge'; + } else if ( userAgent.includes( 'Chrome' ) && ! userAgent.includes( 'Edg' ) ) { + browserName = 'Chrome'; + } else if ( userAgent.includes( 'Safari' ) && ! userAgent.includes( 'Chrome' ) ) { + browserName = 'Safari'; + } else if ( userAgent.includes( 'MSIE' ) || userAgent.includes( 'Trident' ) ) { + browserName = 'Internet Explorer'; + } else if ( userAgent.includes( 'Opera' ) || userAgent.includes( 'OPR' ) ) { + browserName = 'Opera'; + } + + return browserName; +} From f81ed74b9b1ad76a4c49ce065f428b37e1e51bd0 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Fri, 16 Jan 2026 11:50:21 +1100 Subject: [PATCH 02/30] Got the user fetch working --- packages/core-data/src/resolvers.js | 3 ++ .../sync/src/awareness/awareness-manager.ts | 22 +++------ .../sync/src/awareness/awareness-state.ts | 49 +++++++++---------- .../sync/src/awareness/awareness-types.ts | 2 +- packages/sync/src/index.ts | 4 +- packages/sync/src/manager.ts | 20 +++++--- packages/sync/src/test/manager.ts | 15 ++++++ packages/sync/src/types.ts | 2 + packages/sync/src/utils.ts | 24 ++++++--- 9 files changed, 85 insertions(+), 56 deletions(-) diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 39ed5d95b281fa..9787d1e84a4623 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -223,6 +223,9 @@ export const getEntityRecord = key ); }, + // Get the current user. + getCurrentUser: async () => + await resolveSelect.getCurrentUser(), } ); } diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 7c5e56778f59d6..33975c596adaf1 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -3,18 +3,13 @@ */ import type * as Y from 'yjs'; -/** - * WordPress dependencies - */ -import { select } from '@wordpress/data'; - /** * Internal dependencies */ import type { ObjectID, ObjectType } from '../types'; import type { AwarenessState } from './awareness-state'; import { PostEditorAwarenessState } from './post-editor-awareness-state'; -import { UserInfo, WordPressUserInfo } from './awareness-types'; +import type { UserInfo, WordPressUserInfo } from './awareness-types'; import { getBrowserName } from '../utils'; const awarenessInstances: Map< string, AwarenessState > = new Map(); @@ -64,24 +59,21 @@ export function getPostEditorAwareness( /** * Create an awareness instance for the given object type and object ID. - * @param objectType Object type. - * @param objectId Object ID. - * @param ydoc Yjs document. + * @param objectType Object type. + * @param objectId Object ID. + * @param ydoc Yjs document. + * @param currentUser Current user. * @return Awareness instance. */ export async function createAwareness( objectType: ObjectType, objectId: ObjectID | null, - ydoc: Y.Doc + ydoc: Y.Doc, + currentUser: WordPressUserInfo ): Promise< AwarenessState | undefined > { if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); - - const currentUser = select( 'core/data' ).getCurrentUser(); - - console.log( 'currentUser', currentUser ); const userInfo = getUserInfo( currentUser ); - console.log( 'userInfo', userInfo ); awareness.setUp( userInfo ); awarenessInstances.set( diff --git a/packages/sync/src/awareness/awareness-state.ts b/packages/sync/src/awareness/awareness-state.ts index 1faa887426a40e..95ab2c8a6bb1f6 100644 --- a/packages/sync/src/awareness/awareness-state.ts +++ b/packages/sync/src/awareness/awareness-state.ts @@ -1,9 +1,9 @@ /** * Internal dependencies */ +import type { UserInfo } from './awareness-types'; import { TypedAwareness, - UserInfo, type BaseState, type EnhancedState, type EqualityFieldCheck, @@ -150,8 +150,9 @@ export abstract class AwarenessState< /** * Set up. + * @param userInfo */ - public setUp( userInfo: UserInfo): void { + public setUp( userInfo: UserInfo ): void { this.setLocalStateField( 'userInfo', userInfo ); this.on( @@ -232,34 +233,32 @@ export abstract class AwarenessState< const updatedStates = new Map< number, EnhancedState< State > >( [ ...this.disconnectedUsers, ...states.keys() ] - .filter( clientId => { + .filter( ( clientId ) => { // Exclude any users without `userInfo`. // This can happen from the Yjs inspector, which joins the awareness // state without providing user data. return Boolean( this.seenStates.get( clientId )?.userInfo ); } ) - .map( - ( clientId ) => { - // The filter above ensures that seenStates has the clientId. - const rawState: State = this.seenStates.get( clientId )!; - - const isConnected = - ! this.disconnectedUsers.has( clientId ); - const isMe = clientId === this.clientID; - const myState: Partial< State > = isMe - ? this.myThrottledState - : {}; - const state: EnhancedState< State > = { - ...rawState, - ...myState, - clientId, - isConnected, - isMe, - }; - - return [ clientId, state ]; - } - ) + .map( ( clientId ) => { + // The filter above ensures that seenStates has the clientId. + const rawState: State = this.seenStates.get( clientId )!; + + const isConnected = + ! this.disconnectedUsers.has( clientId ); + const isMe = clientId === this.clientID; + const myState: Partial< State > = isMe + ? this.myThrottledState + : {}; + const state: EnhancedState< State > = { + ...rawState, + ...myState, + clientId, + isConnected, + isMe, + }; + + return [ clientId, state ]; + } ) ); if ( ! forceUpdate ) { diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index 0d373a606c6ebf..5baa57860955a4 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -48,7 +48,7 @@ export class TypedAwareness< State extends BaseState > extends Awareness { export interface WordPressUserInfo { id: number; name: string; - avatarUrl?: string; + avatar_urls: Record< string, string >; } /** diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index d17ec2dbc2fa18..e84771bd6e8564 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -25,6 +25,4 @@ export { } from './config'; export { createSyncManager } from './manager'; export type * from './types'; -export { - setConnectionStatus, -} from './awareness/awareness-manager'; +export { setConnectionStatus } from './awareness/awareness-manager'; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 0293b096bf505f..e2e1a743621459 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -78,11 +78,11 @@ export function createSyncManager(): SyncManager { /** * Load an entity for syncing and manage its lifecycle. * - * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {ObjectData} record Entity record representing this object type. - * @param {RecordHandlers} handlers Handlers for updating and fetching the record. + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {ObjectData} record Entity record representing this object type. + * @param {RecordHandlers} handlers Handlers for updating and fetching the record. */ async function loadEntity( syncConfig: SyncConfig, @@ -147,8 +147,16 @@ export function createSyncManager(): SyncManager { entityStates.set( entityId, entityState ); + // Get the current user from the handlers. + const currentUser = await handlers.getCurrentUser(); + // Create awareness for the given entity and its Yjs document. - const awareness = await createAwareness( objectType, objectId, ydoc ); + const awareness = await createAwareness( + objectType, + objectId, + ydoc, + currentUser + ); // Create providers for the given entity and its Yjs document. const providerResults = await Promise.all( diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index 3853caedac1603..9f48bf3aab62ee 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -31,6 +31,7 @@ import type { RecordHandlers, SyncConfig, } from '../types'; +import type { WordPressUserInfo } from '../awareness/awareness-types'; // Mock dependencies. jest.mock( '../providers', () => ( { @@ -43,6 +44,7 @@ describe( 'SyncManager', () => { let mockProviderCreator: jest.Mock< ProviderCreator >; let mockProviderResult: ProviderCreatorResult; let mockRecord: ObjectData; + let mockCurrentUser: WordPressUserInfo; let mockSyncConfig: jest.MockedObject< SyncConfig >; beforeEach( () => { @@ -54,6 +56,16 @@ describe( 'SyncManager', () => { title: 'Test Post', }; + mockCurrentUser = { + id: 1, + name: 'Test User', + avatar_urls: { + '24': 'https://example.com/avatar.jpg', + '48': 'https://example.com/avatar-48.jpg', + '96': 'https://example.com/avatar-96.jpg', + }, + }; + mockProviderResult = { destroy: jest.fn(), }; @@ -89,6 +101,9 @@ describe( 'SyncManager', () => { Promise.resolve( mockRecord ) ), saveRecord: jest.fn( async () => Promise.resolve() ), + getCurrentUser: jest.fn( async () => + Promise.resolve( mockCurrentUser ) + ), }; } ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index c7e39b0e2773bb..9d6525b8e83ba9 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -13,6 +13,7 @@ import type { Awareness } from 'y-protocols/awareness'; * Internal dependencies */ import type { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from './config'; +import type { WordPressUserInfo } from './awareness/awareness-types'; /* globalThis */ declare global { @@ -64,6 +65,7 @@ export interface RecordHandlers { editRecord: ( data: Partial< ObjectData > ) => void; getEditedRecord: () => Promise< ObjectData >; saveRecord: () => Promise< void >; + getCurrentUser: () => Promise< WordPressUserInfo >; } export interface SyncConfig { diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index 7b4d6f7e61de9e..3ae24d1e5a344a 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -111,9 +111,12 @@ export function areMapsEqual< Key, Value >( * Compare two user infos for equality. * @param userInfo1 - The first user info to compare. * @param userInfo2 - The second user info to compare. - * @returns True if the user infos are equal, false otherwise. + * @return True if the user infos are equal, false otherwise. */ -export function areUserInfosEqual( userInfo1?: UserInfo, userInfo2?: UserInfo ): boolean { +export function areUserInfosEqual( + userInfo1?: UserInfo, + userInfo2?: UserInfo +): boolean { if ( ! userInfo1 || ! userInfo2 ) { return userInfo1 === userInfo2; } @@ -130,7 +133,7 @@ export function areUserInfosEqual( userInfo1?: UserInfo, userInfo2?: UserInfo ): /** * Get the browser name from the user agent. - * @returns The browser name. + * @return The browser name. */ export function getBrowserName(): string { const userAgent = window.navigator.userAgent; @@ -140,11 +143,20 @@ export function getBrowserName(): string { browserName = 'Firefox'; } else if ( userAgent.includes( 'Edg' ) ) { browserName = 'Microsoft Edge'; - } else if ( userAgent.includes( 'Chrome' ) && ! userAgent.includes( 'Edg' ) ) { + } else if ( + userAgent.includes( 'Chrome' ) && + ! userAgent.includes( 'Edg' ) + ) { browserName = 'Chrome'; - } else if ( userAgent.includes( 'Safari' ) && ! userAgent.includes( 'Chrome' ) ) { + } else if ( + userAgent.includes( 'Safari' ) && + ! userAgent.includes( 'Chrome' ) + ) { browserName = 'Safari'; - } else if ( userAgent.includes( 'MSIE' ) || userAgent.includes( 'Trident' ) ) { + } else if ( + userAgent.includes( 'MSIE' ) || + userAgent.includes( 'Trident' ) + ) { browserName = 'Internet Explorer'; } else if ( userAgent.includes( 'Opera' ) || userAgent.includes( 'OPR' ) ) { browserName = 'Opera'; From 6e3eb809cb0f9a4a2b5f0e464128a3eb607d380c Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Fri, 16 Jan 2026 15:02:10 +1100 Subject: [PATCH 03/30] Added in some test logging --- .../sync/src/awareness/awareness-manager.ts | 21 ++- .../sync/src/awareness/awareness-types.ts | 2 + .../awareness/post-editor-awareness-state.ts | 17 ++- packages/sync/src/local-storage.ts | 51 +++++++ packages/sync/src/manager.ts | 10 +- packages/sync/src/test/manager.ts | 1 + packages/sync/src/user-utils.ts | 139 ++++++++++++++++++ packages/sync/src/utils.ts | 58 -------- 8 files changed, 230 insertions(+), 69 deletions(-) create mode 100644 packages/sync/src/local-storage.ts create mode 100644 packages/sync/src/user-utils.ts diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 33975c596adaf1..37fd2de05abfe3 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -10,7 +10,7 @@ import type { ObjectID, ObjectType } from '../types'; import type { AwarenessState } from './awareness-state'; import { PostEditorAwarenessState } from './post-editor-awareness-state'; import type { UserInfo, WordPressUserInfo } from './awareness-types'; -import { getBrowserName } from '../utils'; +import { getBrowserName, getNewUserColor } from '../user-utils'; const awarenessInstances: Map< string, AwarenessState > = new Map(); @@ -28,10 +28,23 @@ function getAwarenessInstance( return awarenessInstances.get( getAwarenessId( objectType, objectId ) ); } -function getUserInfo( wpUser: WordPressUserInfo ): UserInfo { +function getUserInfo( + awareness: AwarenessState, + wpUser: WordPressUserInfo +): UserInfo { + const states = awareness.getStates(); + const otherUserColors = Array.from( states.entries() ) + .filter( + ( [ clientId, state ] ) => + state.userInfo && clientId !== awareness.clientID + ) + .map( ( [ _clientId, state ] ) => state.userInfo.color ) + .filter( Boolean ); + return { ...wpUser, browserType: getBrowserName(), + color: getNewUserColor( otherUserColors ), enteredAt: Date.now(), }; } @@ -73,8 +86,7 @@ export async function createAwareness( ): Promise< AwarenessState | undefined > { if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); - const userInfo = getUserInfo( currentUser ); - + const userInfo = getUserInfo( awareness, currentUser ); awareness.setUp( userInfo ); awarenessInstances.set( getAwarenessId( objectType, objectId ), @@ -83,7 +95,6 @@ export async function createAwareness( return awareness; } - return undefined; } diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index 5baa57860955a4..bc4e5eb93364ff 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -48,6 +48,7 @@ export class TypedAwareness< State extends BaseState > extends Awareness { export interface WordPressUserInfo { id: number; name: string; + slug: string; avatar_urls: Record< string, string >; } @@ -57,6 +58,7 @@ export interface WordPressUserInfo { */ export interface UserInfo extends WordPressUserInfo { browserType: string; + color: string; enteredAt: number; } diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index 0a36bbc9af569c..9ebae5b554e6fd 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -1,9 +1,14 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; /** * Internal dependencies */ import type { PostEditorState, UserInfo } from './awareness-types'; import { AwarenessState } from './awareness-state'; -import { areUserInfosEqual } from '../utils'; +import { areUserInfosEqual } from '../user-utils'; +import { CRDT_RECORD_MAP_KEY } from '../config'; export class PostEditorAwarenessState extends AwarenessState< PostEditorState > { protected equalityFieldChecks = { @@ -12,6 +17,16 @@ export class PostEditorAwarenessState extends AwarenessState< PostEditorState > public setUp( userInfo: UserInfo ): void { super.setUp( userInfo ); + + this.subscribeToCRDTChanges(); + } + + private subscribeToCRDTChanges(): void { + const recordMap = this.doc.getMap( CRDT_RECORD_MAP_KEY ); + + recordMap.observeDeep( ( changes ) => { + console.log( changes ); + } ); } // TODO: Add in subscription for user selection changes. diff --git a/packages/sync/src/local-storage.ts b/packages/sync/src/local-storage.ts new file mode 100644 index 00000000000000..b249d1306183b1 --- /dev/null +++ b/packages/sync/src/local-storage.ts @@ -0,0 +1,51 @@ +/** + * Load data from localStorage with error handling + * @param key - The localStorage key to read from + * @param defaultValue - The default value to return if loading fails or key doesn't exist + * @return The parsed data from localStorage or the default value + */ +export const loadFromLocalStorage = < T >( + key: string, + defaultValue: T +): T => { + try { + const saved = window?.localStorage?.getItem( key ); + if ( saved ) { + const parsed = JSON.parse( saved ) as T; + // If the parsed value is an object (and not null or array), merge with defaultValue + if ( + typeof parsed === 'object' && + parsed !== null && + ! Array.isArray( parsed ) + ) { + return { ...defaultValue, ...( parsed as Partial< T > ) }; + } + // For primitive values (string, number, boolean, null), return directly + return parsed; + } + } catch ( error ) { + // eslint-disable-next-line no-console + console.warn( + `Failed to load data from localStorage (key: ${ key }):`, + error + ); + } + return defaultValue; +}; + +/** + * Save data to localStorage with error handling + * @param key - The localStorage key to write to + * @param data - The data to save + */ +export const saveToLocalStorage = < T >( key: string, data: T ): void => { + try { + localStorage.setItem( key, JSON.stringify( data ) ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.warn( + `Failed to save data to localStorage (key: ${ key }):`, + error + ); + } +}; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index e2e1a743621459..818404c98979dc 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -78,11 +78,11 @@ export function createSyncManager(): SyncManager { /** * Load an entity for syncing and manage its lifecycle. * - * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {ObjectData} record Entity record representing this object type. - * @param {RecordHandlers} handlers Handlers for updating and fetching the record. + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {ObjectData} record Entity record representing this object type. + * @param {RecordHandlers} handlers Handlers for updating and fetching the record. */ async function loadEntity( syncConfig: SyncConfig, diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index 9f48bf3aab62ee..565542a32e8588 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -59,6 +59,7 @@ describe( 'SyncManager', () => { mockCurrentUser = { id: 1, name: 'Test User', + slug: 'test-user', avatar_urls: { '24': 'https://example.com/avatar.jpg', '48': 'https://example.com/avatar-48.jpg', diff --git a/packages/sync/src/user-utils.ts b/packages/sync/src/user-utils.ts new file mode 100644 index 00000000000000..1a7a9a1ab25589 --- /dev/null +++ b/packages/sync/src/user-utils.ts @@ -0,0 +1,139 @@ +import type { UserInfo } from './awareness/awareness-types'; +import { loadFromLocalStorage, saveToLocalStorage } from './local-storage'; + +/** + * The color palette for the user highlight. + */ +const COLOR_PALETTE = [ + '#3858E9', // blueberry + '#B42AED', // purple + '#E33184', // pink + '#F3661D', // orange + '#ECBD3A', // yellow + '#97FE17', // green + '#00FDD9', // teal + '#37C5F0', // cyan +]; + +const LOCAL_STORAGE_KEY = 'gutenberg-rtc-preferred-color'; + +/** + * Generate a random integer between min and max, inclusive. + * + * @param min - The minimum value. + * @param max - The maximum value. + * @return A random integer between min and max. + */ +function generateRandomInt( min: number, max: number ): number { + return Math.floor( Math.random() * ( max - min + 1 ) ) + min; +} + +/** + * Get a unique user color from the palette, or generate a variation if none are available. + * If the previously used color is available from localStorage, use it. + * + * @param existingColors - Colors that are already in use. + * @return The new user color, in hex format. + */ +export function getNewUserColor( existingColors: string[] ): string { + const availableColors = COLOR_PALETTE.filter( + ( color ) => ! existingColors.includes( color ) + ); + + const preferredColor = loadFromLocalStorage< string | null >( + LOCAL_STORAGE_KEY, + null + ); + + let hexColor: string; + + if ( preferredColor && availableColors.includes( preferredColor ) ) { + hexColor = preferredColor; + } else if ( availableColors.length > 0 ) { + const randomIndex = generateRandomInt( 0, availableColors.length - 1 ); + hexColor = availableColors[ randomIndex ]; + } else { + // All colors are used, generate a variation of a random palette color + const randomIndex = generateRandomInt( 0, COLOR_PALETTE.length - 1 ); + const baseColor = COLOR_PALETTE[ randomIndex ]; + hexColor = generateColorVariation( baseColor ); + } + + saveToLocalStorage( LOCAL_STORAGE_KEY, hexColor ); + return hexColor; +} + +/** + * Generate a variation of a hex color by adjusting its lightness. + * + * @param hexColor - The base hex color (e.g., '#3858E9'). + * @return A varied hex color. + */ +function generateColorVariation( hexColor: string ): string { + // Parse hex to RGB + const r = parseInt( hexColor.slice( 1, 3 ), 16 ); + const g = parseInt( hexColor.slice( 3, 5 ), 16 ); + const b = parseInt( hexColor.slice( 5, 7 ), 16 ); + + // Apply a random lightness shift (-30 to +30) + const shift = generateRandomInt( -30, 30 ); + const newR = Math.min( 255, Math.max( 0, r + shift ) ); + const newG = Math.min( 255, Math.max( 0, g + shift ) ); + const newB = Math.min( 255, Math.max( 0, b + shift ) ); + + // Convert back to hex + const toHex = ( n: number ) => n.toString( 16 ).padStart( 2, '0' ).toUpperCase(); + return `#${ toHex( newR ) }${ toHex( newG ) }${ toHex( newB ) }`; +} + +/** + * Get the browser name from the user agent. + * @return The browser name. + */ +export function getBrowserName(): string { + const userAgent = window.navigator.userAgent; + let browserName = 'Unknown'; + + if ( userAgent.includes( 'Firefox' ) ) { + browserName = 'Firefox'; + } else if ( userAgent.includes( 'Edg' ) ) { + browserName = 'Microsoft Edge'; + } else if ( + userAgent.includes( 'Chrome' ) && + ! userAgent.includes( 'Edg' ) + ) { + browserName = 'Chrome'; + } else if ( + userAgent.includes( 'Safari' ) && + ! userAgent.includes( 'Chrome' ) + ) { + browserName = 'Safari'; + } else if ( + userAgent.includes( 'MSIE' ) || + userAgent.includes( 'Trident' ) + ) { + browserName = 'Internet Explorer'; + } else if ( userAgent.includes( 'Opera' ) || userAgent.includes( 'OPR' ) ) { + browserName = 'Opera'; + } + + return browserName; +} + +export function areUserInfosEqual( + userInfo1?: UserInfo, + userInfo2?: UserInfo +): boolean { + if ( ! userInfo1 || ! userInfo2 ) { + return userInfo1 === userInfo2; + } + + if ( Object.keys( userInfo1 ).length !== Object.keys( userInfo2 ).length ) { + return false; + } + + return Object.entries( userInfo1 ).every( ( [ key, value ] ) => { + // Update this function with any non-primitive fields added to UserInfo. + return value === userInfo2[ key as keyof UserInfo ]; + } ); +} diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index 3ae24d1e5a344a..baa9373223f27d 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -106,61 +106,3 @@ export function areMapsEqual< Key, Value >( return true; } - -/** - * Compare two user infos for equality. - * @param userInfo1 - The first user info to compare. - * @param userInfo2 - The second user info to compare. - * @return True if the user infos are equal, false otherwise. - */ -export function areUserInfosEqual( - userInfo1?: UserInfo, - userInfo2?: UserInfo -): boolean { - if ( ! userInfo1 || ! userInfo2 ) { - return userInfo1 === userInfo2; - } - - if ( Object.keys( userInfo1 ).length !== Object.keys( userInfo2 ).length ) { - return false; - } - - return Object.entries( userInfo1 ).every( ( [ key, value ] ) => { - // Update this function with any non-primitive fields added to UserInfo. - return value === userInfo2[ key as keyof UserInfo ]; - } ); -} - -/** - * Get the browser name from the user agent. - * @return The browser name. - */ -export function getBrowserName(): string { - const userAgent = window.navigator.userAgent; - let browserName = 'Unknown'; - - if ( userAgent.includes( 'Firefox' ) ) { - browserName = 'Firefox'; - } else if ( userAgent.includes( 'Edg' ) ) { - browserName = 'Microsoft Edge'; - } else if ( - userAgent.includes( 'Chrome' ) && - ! userAgent.includes( 'Edg' ) - ) { - browserName = 'Chrome'; - } else if ( - userAgent.includes( 'Safari' ) && - ! userAgent.includes( 'Chrome' ) - ) { - browserName = 'Safari'; - } else if ( - userAgent.includes( 'MSIE' ) || - userAgent.includes( 'Trident' ) - ) { - browserName = 'Internet Explorer'; - } else if ( userAgent.includes( 'Opera' ) || userAgent.includes( 'OPR' ) ) { - browserName = 'Opera'; - } - - return browserName; -} From cf05627081ab88b92d6b49be46707662eae75168 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Fri, 16 Jan 2026 15:46:45 +1100 Subject: [PATCH 04/30] Attempting to fix the sync --- .../sync/src/awareness/awareness-manager.ts | 19 +++++++-- .../awareness/post-editor-awareness-state.ts | 41 +++++++++++++++++-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 37fd2de05abfe3..68eb20c00f81a9 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -33,14 +33,23 @@ function getUserInfo( wpUser: WordPressUserInfo ): UserInfo { const states = awareness.getStates(); + console.log( 'clientID:', awareness.clientID ); + console.log( 'states snapshot:', JSON.stringify( Object.fromEntries( states ) ) ); const otherUserColors = Array.from( states.entries() ) .filter( - ( [ clientId, state ] ) => - state.userInfo && clientId !== awareness.clientID + ( [ clientId, state ] ) => { + console.log( { clientId, state } ); + return state.userInfo && clientId !== awareness.clientID; + } ) - .map( ( [ _clientId, state ] ) => state.userInfo.color ) + .map( ( [ _clientId, state ] ) => { + console.log( { state } ); + return state.userInfo?.color; + } ) .filter( Boolean ); + console.log( { otherUserColors } ); + return { ...wpUser, browserType: getBrowserName(), @@ -86,6 +95,10 @@ export async function createAwareness( ): Promise< AwarenessState | undefined > { if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); + + // Wait for initial sync before assigning color + await new Promise( ( resolve ) => setTimeout( resolve, 500 ) ); + const userInfo = getUserInfo( awareness, currentUser ); awareness.setUp( userInfo ); awarenessInstances.set( diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index 9ebae5b554e6fd..a2ca03de078bb6 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -8,7 +8,7 @@ import * as Y from 'yjs'; import type { PostEditorState, UserInfo } from './awareness-types'; import { AwarenessState } from './awareness-state'; import { areUserInfosEqual } from '../user-utils'; -import { CRDT_RECORD_MAP_KEY } from '../config'; +import { CRDT_RECORD_METADATA_SAVED_AT_KEY, CRDT_RECORD_METADATA_SAVED_BY_KEY, CRDT_RECORD_METADATA_MAP_KEY } from '../config'; export class PostEditorAwarenessState extends AwarenessState< PostEditorState > { protected equalityFieldChecks = { @@ -22,10 +22,43 @@ export class PostEditorAwarenessState extends AwarenessState< PostEditorState > } private subscribeToCRDTChanges(): void { - const recordMap = this.doc.getMap( CRDT_RECORD_MAP_KEY ); + const now = Date.now(); + const recordMeta = this.doc.getMap( CRDT_RECORD_METADATA_MAP_KEY ); - recordMap.observeDeep( ( changes ) => { - console.log( changes ); + recordMeta.observe( ( event: Y.YMapEvent< unknown >, transaction: Y.Transaction ) => { + if ( transaction.local ) { + return; + } + + event.keysChanged.forEach( ( key: string ) => { + switch ( key ) { + // A remote user has saved the document. + case CRDT_RECORD_METADATA_SAVED_AT_KEY: { + const savedTimestamp = recordMeta.get( CRDT_RECORD_METADATA_SAVED_AT_KEY ); + const remoteClientId = recordMeta.get( CRDT_RECORD_METADATA_SAVED_BY_KEY ); + + // Type / "undefined" guard. + if ( 'number' !== typeof remoteClientId || 'number' !== typeof savedTimestamp ) { + break; + } + + const userState = this.getStates().get( remoteClientId ); + + if ( + // Ignore if the savedAt timestamp is older than our session + now > savedTimestamp || + // Ignore if we don't have a user state for the client ID + ! userState || + // Ignore if this is our own saved event (can happen on refresh or reconnect) + userState.userInfo.id === this.getLocalStateField( 'userInfo' )?.id + ) { + break; + } + + console.log( 'Document was saved by client ID', remoteClientId ); + } + } + } ); } ); } From 2c1d5cf274cc2c0995652928be07b56e3a031245 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 08:23:44 +1100 Subject: [PATCH 05/30] Add a comment explining the bug --- .../sync/src/awareness/awareness-manager.ts | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 68eb20c00f81a9..64d0499fe63283 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -33,23 +33,15 @@ function getUserInfo( wpUser: WordPressUserInfo ): UserInfo { const states = awareness.getStates(); - console.log( 'clientID:', awareness.clientID ); - console.log( 'states snapshot:', JSON.stringify( Object.fromEntries( states ) ) ); + // TODO: There is a timing issue here. The other users aren't yet synced, and as a result the same color could be assigned to multiple users. const otherUserColors = Array.from( states.entries() ) .filter( - ( [ clientId, state ] ) => { - console.log( { clientId, state } ); - return state.userInfo && clientId !== awareness.clientID; - } + ( [ clientId, state ] ) => + state.userInfo && clientId !== awareness.clientID ) - .map( ( [ _clientId, state ] ) => { - console.log( { state } ); - return state.userInfo?.color; - } ) + .map( ( [ _clientId, state ] ) => state.userInfo.color ) .filter( Boolean ); - console.log( { otherUserColors } ); - return { ...wpUser, browserType: getBrowserName(), @@ -95,10 +87,6 @@ export async function createAwareness( ): Promise< AwarenessState | undefined > { if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); - - // Wait for initial sync before assigning color - await new Promise( ( resolve ) => setTimeout( resolve, 500 ) ); - const userInfo = getUserInfo( awareness, currentUser ); awareness.setUp( userInfo ); awarenessInstances.set( From 8772cb2e4043f89d8fc67b10eb47ce0bae055cb9 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 15:09:32 +1100 Subject: [PATCH 06/30] Add support for selection --- package-lock.json | 1 + packages/core-data/src/resolvers.js | 3 +- packages/sync/README.md | 10 + packages/sync/package.json | 4 +- .../sync/src/awareness/awareness-manager.ts | 2 +- .../sync/src/awareness/awareness-state.ts | 37 +- .../sync/src/awareness/awareness-types.ts | 36 +- .../awareness/post-editor-awareness-state.ts | 138 +++++-- packages/sync/src/config.ts | 10 + packages/sync/src/manager.ts | 1 + packages/sync/src/selection-utils.ts | 366 ++++++++++++++++++ packages/sync/src/types.ts | 5 +- packages/sync/src/user-utils.ts | 3 +- packages/sync/src/utils.ts | 1 - packages/sync/tsconfig.json | 2 + 15 files changed, 566 insertions(+), 53 deletions(-) create mode 100644 packages/sync/src/selection-utils.ts diff --git a/package-lock.json b/package-lock.json index 75c8294415d78c..3e70bd8e60d1e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54919,6 +54919,7 @@ "license": "GPL-2.0-or-later", "dependencies": { "@types/diff": "7.0.2", + "@wordpress/block-editor": "file:../block-editor", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 05a266f11d7b78..c990c2ad226f72 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -192,7 +192,7 @@ export const getEntityRecord = recordWithTransients, { // Handle edits sourced from the sync manager. - editRecord: ( edits ) => { + editRecord: ( edits, options = {} ) => { if ( ! Object.keys( edits ).length ) { return; } @@ -206,6 +206,7 @@ export const getEntityRecord = meta: { undo: undefined, }, + options, } ); }, // Get the current entity record (with edits) diff --git a/packages/sync/README.md b/packages/sync/README.md index 2dd2b37cc4056c..8828c965b2db6e 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -50,6 +50,16 @@ Origin string for CRDT document changes originating from the local editor. Origin string for CRDT document changes originating from the sync manager. +### setConnectionStatus + +Set the current user's connection status in the awareness instance for the given object type and object ID. + +_Parameters_ + +- _objectType_ `ObjectType`: Object type. +- _objectId_ `ObjectID | null`: Object ID. +- _isConnected_ `boolean`: Connection status. + ### WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE WordPress meta key used to persist the CRDT document for an entity. diff --git a/packages/sync/package.json b/packages/sync/package.json index 92c13425be0673..959c51dc1201c7 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -30,7 +30,6 @@ "*.md" ], "main": "build/index.cjs", - "wpScript": true, "module": "build-module/index.mjs", "exports": { ".": { @@ -41,10 +40,13 @@ "./package.json": "./package.json" }, "react-native": "src/index", + "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { "@types/diff": "7.0.2", + "@wordpress/block-editor": "file:../block-editor", + "@wordpress/data": "file:../data", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 64d0499fe63283..e9b979e384a2d1 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -39,7 +39,7 @@ function getUserInfo( ( [ clientId, state ] ) => state.userInfo && clientId !== awareness.clientID ) - .map( ( [ _clientId, state ] ) => state.userInfo.color ) + .map( ( [ , state ] ) => state.userInfo.color ) .filter( Boolean ); return { diff --git a/packages/sync/src/awareness/awareness-state.ts b/packages/sync/src/awareness/awareness-state.ts index 95ab2c8a6bb1f6..848208db6be4a9 100644 --- a/packages/sync/src/awareness/awareness-state.ts +++ b/packages/sync/src/awareness/awareness-state.ts @@ -10,6 +10,7 @@ import { } from './awareness-types'; import { getTypedKeys, areMapsEqual } from '../utils'; import { REMOVAL_DELAY_IN_MS } from '../config'; +import type { RecordHandlers } from '../types'; type AwarenessClientID = number; @@ -145,14 +146,16 @@ export abstract class AwarenessState< * value -- even if it hasn't yet been set on the awareness instance. */ private myThrottledState: Partial< State > = {}; + private throttleTimeouts: Map< string, NodeJS.Timeout > = new Map(); /** CUSTOM METHODS */ /** - * Set up. - * @param userInfo + * Set up the awareness state. + * @param _recordHandlers - Record handlers. + * @param userInfo - User info. */ - public setUp( userInfo: UserInfo ): void { + public setUp( _recordHandlers: RecordHandlers, userInfo: UserInfo ): void { this.setLocalStateField( 'userInfo', userInfo ); this.on( @@ -201,6 +204,34 @@ export abstract class AwarenessState< }; } + /** + * Set a local state field on an awareness document with throttle. See caveats + * of this.setLocalStateField. + * @param field + * @param value + * @param wait + */ + public setThrottledLocalStateField< + FieldName extends string & keyof State, + >( field: FieldName, value: State[ FieldName ], wait: number ): void { + this.setLocalStateField( field, value ); + + this.throttleTimeouts.set( + field, + setTimeout( () => { + this.throttleTimeouts.delete( field ); + if ( this.myThrottledState[ field ] ) { + this.setLocalStateField( + field, + this.myThrottledState[ field ] + ); + + delete this.myThrottledState[ field ]; + } + }, wait ) + ); + } + /** * Set the current user's connection status as awareness state. * @param isConnected diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index bc4e5eb93364ff..ce041c73549327 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -1,6 +1,7 @@ import { Awareness } from 'y-protocols/awareness'; import { getRecordValue } from '../utils'; +import type { SelectionState } from '../selection-utils'; /** * Extended Awareness class with typed state accessors. @@ -81,15 +82,44 @@ export type EnhancedState< State extends BaseState > = State & { isMe: boolean; }; +/** + * A block selection object. + * + * In order to avoid circular dependencies, we define it here instead of importing + * the WPBlockSelection interface from @wordpress/editor. + */ +export type WPBlockSelection = { + /** + * A block client ID. + */ + clientId: string; + /** + * A block attribute key. + */ + attributeKey: string; + /** + * An attribute value offset, based on the rich + * text value. See `wp.richText.create`. + */ + offset: number; +}; + export type EqualityFieldCheck< State extends BaseState, FieldName extends keyof State, > = ( value1?: State[ FieldName ], value2?: State[ FieldName ] ) => boolean; +/** + * 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. - * - * TODO: Add in the presence indicators. */ -export interface PostEditorState extends BaseState {} +export interface PostEditorState extends BaseState { + editorState?: EditorState; +} diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index a2ca03de078bb6..8f27466ef780c6 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -1,66 +1,122 @@ /** * External dependencies */ -import * as Y from 'yjs'; +import type * as Y from 'yjs'; + +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { select, subscribe } from '@wordpress/data'; +// @ts-expect-error No exported types for block editor store selectors. +import { type BlockEditorStoreSelectors } from '@wordpress/block-editor/build-types/store/selectors'; + /** * Internal dependencies */ -import type { PostEditorState, UserInfo } from './awareness-types'; +import type { + PostEditorState, + UserInfo, + WPBlockSelection, +} from './awareness-types'; +import type { RecordHandlers } from '../types'; import { AwarenessState } from './awareness-state'; import { areUserInfosEqual } from '../user-utils'; -import { CRDT_RECORD_METADATA_SAVED_AT_KEY, CRDT_RECORD_METADATA_SAVED_BY_KEY, CRDT_RECORD_METADATA_MAP_KEY } from '../config'; +import { + LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, + AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS, + CRDT_RECORD_MAP_KEY, +} from '../config'; +import type { SelectableBlock } from '../selection-utils'; +import { + updateSelectionInEntityRecord, + getSelectionState, + areEditorStatesEqual, +} from '../selection-utils'; export class PostEditorAwarenessState extends AwarenessState< PostEditorState > { protected equalityFieldChecks = { + editorState: areEditorStatesEqual, userInfo: areUserInfosEqual, }; - public setUp( userInfo: UserInfo ): void { - super.setUp( userInfo ); + public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { + super.setUp( recordHandlers, userInfo ); - this.subscribeToCRDTChanges(); + this.subscribeToSelectionChanges( recordHandlers ); } - private subscribeToCRDTChanges(): void { - const now = Date.now(); - const recordMeta = this.doc.getMap( CRDT_RECORD_METADATA_MAP_KEY ); + private subscribeToSelectionChanges( handlers: RecordHandlers ): void { + const { + getSelectionStart, + getSelectionEnd, + getSelectedBlocksInitialCaretPosition, + } = select( blockEditorStore ) as BlockEditorStoreSelectors; + + // 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; + + // Provided type is generic `Function`. + + subscribe( () => { + const newSelectionStart = getSelectionStart(); + const newSelectionEnd = getSelectionEnd(); - recordMeta.observe( ( event: Y.YMapEvent< unknown >, transaction: Y.Transaction ) => { - if ( transaction.local ) { + if ( + newSelectionStart === selectionStart && + newSelectionEnd === selectionEnd + ) { return; } - event.keysChanged.forEach( ( key: string ) => { - switch ( key ) { - // A remote user has saved the document. - case CRDT_RECORD_METADATA_SAVED_AT_KEY: { - const savedTimestamp = recordMeta.get( CRDT_RECORD_METADATA_SAVED_AT_KEY ); - const remoteClientId = recordMeta.get( CRDT_RECORD_METADATA_SAVED_BY_KEY ); - - // Type / "undefined" guard. - if ( 'number' !== typeof remoteClientId || 'number' !== typeof savedTimestamp ) { - break; - } - - const userState = this.getStates().get( remoteClientId ); - - if ( - // Ignore if the savedAt timestamp is older than our session - now > savedTimestamp || - // Ignore if we don't have a user state for the client ID - ! userState || - // Ignore if this is our own saved event (can happen on refresh or reconnect) - userState.userInfo.id === this.getLocalStateField( 'userInfo' )?.id - ) { - break; - } - - console.log( 'Document was saved by client ID', remoteClientId ); - } - } - } ); + 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. + void updateSelectionInEntityRecord( + handlers, + selectionStart, + selectionEnd, + getSelectedBlocksInitialCaretPosition() + ); + + // 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( () => { + this.updateSelectionState( selectionStart, selectionEnd ); + }, LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS ); } ); } - // TODO: Add in subscription for user selection changes. + private updateSelectionState( + selectionStart: WPBlockSelection, + selectionEnd: WPBlockSelection + ): void { + const ydoc = this.doc.getMap( CRDT_RECORD_MAP_KEY ); + const yBlocks = ydoc.get( 'blocks' ) as Y.Array< SelectableBlock >; + const selection = getSelectionState( + selectionStart, + selectionEnd, + yBlocks + ); + + // Throttle remote awareness updates. + this.setThrottledLocalStateField( + 'editorState', + { selection }, + AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS + ); + } } diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index 11eec60c670699..67bb5cf108e07a 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -63,3 +63,13 @@ export const WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document'; * Delay in milliseconds before removing a user from presence indicators. */ export const REMOVAL_DELAY_IN_MS = 5000; + +/** + * Delay in milliseconds before updating the cursor position. + */ +export const LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS = 500; + +/** + * Delay in milliseconds before throttling the cursor position updates. + */ +export const AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS = 100; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 05ed5f0234db72..4f856aad1ca66a 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -182,6 +182,7 @@ export function createSyncManager(): SyncManager { objectType, objectId, ydoc, + handlers, currentUser ); diff --git a/packages/sync/src/selection-utils.ts b/packages/sync/src/selection-utils.ts new file mode 100644 index 00000000000000..51a70a73fa5d62 --- /dev/null +++ b/packages/sync/src/selection-utils.ts @@ -0,0 +1,366 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import type { EditorState } from './awareness/awareness-types'; +import { type WPBlockSelection } from './awareness/awareness-types'; +import type { RecordHandlers } from './types'; + +/** + * Convenience types to manage block values with a clientId, attributes, and innerBlocks. + */ +type BlockClientId = string; +type BlockInnerBlocks = Y.Array< SelectableBlock >; +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; + +/** + * Converts WordPress block editor selection to a SelectionState. + * + * @param selectionStart - The start position of the selection + * @param selectionEnd - The end position of the selection + * @param yBlocks + * @return The SelectionState + */ +export function getSelectionState( + selectionStart: WPBlockSelection, + selectionEnd: WPBlockSelection, + yBlocks: Y.Array< SelectableBlock > +): SelectionState { + const isSelectionEmpty = Object.keys( selectionStart ).length === 0; + const noSelection: SelectionNone = { + type: SelectionType.None, + }; + + if ( isSelectionEmpty ) { + // Case 1: No selection + return noSelection; + } + + // When the page initially loads, selectionStart can contain an empty object `{}`. + const isSelectionInOneBlock = + selectionStart.clientId === selectionEnd.clientId; + const isCursorOnly = + isSelectionInOneBlock && selectionStart.offset === selectionEnd.offset; + const isSelectionAWholeBlock = + isSelectionInOneBlock && + selectionStart.offset === undefined && + selectionEnd.offset === undefined; + + if ( isSelectionAWholeBlock ) { + // Case 2: A whole block is selected. + return { + type: SelectionType.WholeBlock, + blockId: selectionStart.clientId, + }; + } else if ( isCursorOnly ) { + // Case 3: Cursor only, no text selected + const cursorPosition = getCursorPosition( selectionStart, yBlocks ); + + if ( ! cursorPosition ) { + // If we can't find the cursor position in block text, treat it as a non-selection. + return noSelection; + } + + return { + type: SelectionType.Cursor, + blockId: selectionStart.clientId, + cursorPosition, + }; + } else if ( isSelectionInOneBlock ) { + // Case 4: Selection in a single block + const cursorStartPosition = getCursorPosition( + selectionStart, + yBlocks + ); + const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks ); + + if ( ! cursorStartPosition || ! cursorEndPosition ) { + // If we can't find the cursor positions in block text, treat it as a non-selection. + return noSelection; + } + + return { + type: SelectionType.SelectionInOneBlock, + blockId: selectionStart.clientId, + cursorStartPosition, + cursorEndPosition, + }; + } + + // Caes 5: Selection in multiple blocks + const cursorStartPosition = getCursorPosition( selectionStart, yBlocks ); + const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks ); + if ( ! cursorStartPosition || ! cursorEndPosition ) { + // If we can't find the cursor positions in block text, treat it as a non-selection. + return noSelection; + } + + return { + type: SelectionType.SelectionInMultipleBlocks, + blockStartId: selectionStart.clientId, + blockEndId: selectionEnd.clientId, + cursorStartPosition, + cursorEndPosition, + }; +} + +/** + * Update the entity record with the current user's selection. + * + * @param handlers - Record handlers. + * @param selectionStart - The start position of the selection. + * @param selectionEnd - The end position of the selection. + * @param initialPosition - The initial position of the selection. + */ +export async function updateSelectionInEntityRecord( + handlers: RecordHandlers, + selectionStart: WPBlockSelection, + selectionEnd: WPBlockSelection, + initialPosition: number | null +): Promise< void > { + if ( ! selectionStart.clientId ) { + return; + } + + // 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, + }; + + handlers.editRecord( edits, options ); +} + +export function getCursorPosition( + selection: WPBlockSelection, + blocks: Y.Array< SelectableBlock > +): CursorPosition | null { + const block = findBlockByClientId( selection.clientId, blocks ); + if ( ! block ) { + return null; + } + + const attributes = block.get( 'attributes' ) as Y.Map< Y.Text >; + const currentYText = attributes.get( selection.attributeKey ) as Y.Text; + + const relativePosition = Y.createRelativePositionFromTypeIndex( + currentYText, + selection.offset + ); + + return { + relativePosition, + absoluteOffset: selection.offset, + }; +} + +function findBlockByClientId( + blockId: string, + blocks: Y.Array< SelectableBlock > +): SelectableBlock | null { + for ( const block of blocks ) { + if ( block.get( 'clientId' ) === blockId ) { + return block; + } + + const innerBlocks = block.get( 'innerBlocks' ) as BlockInnerBlocks; + + if ( innerBlocks.length > 0 ) { + const innerBlock = findBlockByClientId( + blockId, + block.get( 'innerBlocks' ) as Y.Array< SelectableBlock > + ); + + if ( innerBlock ) { + return innerBlock; + } + } + } + + return null; +} + +export function areSelectionsEqual( + selection1: SelectionState, + selection2: SelectionState +): boolean { + if ( selection1.type !== selection2.type ) { + return false; + } + + switch ( selection1.type ) { + case SelectionType.None: + return true; + + case SelectionType.Cursor: + return ( + selection1.blockId === + ( selection2 as SelectionCursor ).blockId && + areCursorPositionsEqual( + selection1.cursorPosition, + ( selection2 as SelectionCursor ).cursorPosition + ) + ); + + case SelectionType.SelectionInOneBlock: + return ( + selection1.blockId === + ( selection2 as SelectionInOneBlock ).blockId && + areCursorPositionsEqual( + selection1.cursorStartPosition, + ( selection2 as SelectionInOneBlock ).cursorStartPosition + ) && + areCursorPositionsEqual( + selection1.cursorEndPosition, + ( selection2 as SelectionInOneBlock ).cursorEndPosition + ) + ); + + case SelectionType.SelectionInMultipleBlocks: + return ( + selection1.blockStartId === + ( selection2 as SelectionInMultipleBlocks ).blockStartId && + selection1.blockEndId === + ( selection2 as SelectionInMultipleBlocks ).blockEndId && + areCursorPositionsEqual( + selection1.cursorStartPosition, + ( selection2 as SelectionInMultipleBlocks ) + .cursorStartPosition + ) && + areCursorPositionsEqual( + selection1.cursorEndPosition, + ( selection2 as SelectionInMultipleBlocks ) + .cursorEndPosition + ) + ); + case SelectionType.WholeBlock: + return ( + selection1.blockId === + ( selection2 as SelectionWholeBlock ).blockId + ); + + default: + //logger.error( 'Unable to compare selection types:', selection1, selection2 ); + return false; + } +} + +function areCursorPositionsEqual( + cursorPosition1: CursorPosition, + cursorPosition2: CursorPosition +): boolean { + const isRelativePositionEqual = + JSON.stringify( cursorPosition1.relativePosition ) === + JSON.stringify( cursorPosition2.relativePosition ); + + // Ensure a change in calculated absolute offset results in a treating the cursor as modified. + // This is necessary because Y.Text relative positions can remain the same after text changes. + const isAbsoluteOffsetEqual = + cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset; + + return isRelativePositionEqual && isAbsoluteOffsetEqual; +} + +export function areEditorStatesEqual( + state1?: EditorState, + state2?: EditorState +): boolean { + if ( ! state1 || ! state2 ) { + return state1 === state2; + } + + return areSelectionsEqual( state1.selection, state2.selection ); +} diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 60378deb374e95..c815e817705db5 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -62,7 +62,10 @@ export type ProviderCreator = ( ) => Promise< ProviderCreatorResult >; export interface RecordHandlers { - editRecord: ( data: Partial< ObjectData > ) => void; + editRecord: ( + data: Partial< ObjectData >, + options?: { undoIgnore?: boolean } + ) => void; getEditedRecord: () => Promise< ObjectData >; refetchRecord: () => Promise< void >; saveRecord: () => Promise< void >; diff --git a/packages/sync/src/user-utils.ts b/packages/sync/src/user-utils.ts index 1a7a9a1ab25589..51a059a87c58ca 100644 --- a/packages/sync/src/user-utils.ts +++ b/packages/sync/src/user-utils.ts @@ -82,7 +82,8 @@ function generateColorVariation( hexColor: string ): string { const newB = Math.min( 255, Math.max( 0, b + shift ) ); // Convert back to hex - const toHex = ( n: number ) => n.toString( 16 ).padStart( 2, '0' ).toUpperCase(); + const toHex = ( n: number ) => + n.toString( 16 ).padStart( 2, '0' ).toUpperCase(); return `#${ toHex( newR ) }${ toHex( newG ) }${ toHex( newB ) }`; } diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts index baa9373223f27d..fcd77dc6db2661 100644 --- a/packages/sync/src/utils.ts +++ b/packages/sync/src/utils.ts @@ -14,7 +14,6 @@ import { CRDT_STATE_VERSION_KEY, } from './config'; import type { CRDTDoc } from './types'; -import type { UserInfo } from './awareness/awareness-types'; // An object representation of CRDT document metadata. type DocumentMeta = Record< string, DocumentMetaValue >; diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index 3dee2350432214..b8d226845cb662 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -6,6 +6,8 @@ }, "exclude": [ "src/y-utilities/y-multidoc-undomanager.js" ], "references": [ + { "path": "../block-editor" }, + { "path": "../data" }, { "path": "../hooks" }, { "path": "../undo-manager" }, { "path": "../url" } From e68cfde1c428139232e022c882a541585825ba34 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 15:13:40 +1100 Subject: [PATCH 07/30] Ensure the pckakge-lock changes are in --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index 4332b25d2a11a5..0fac6a160d6c79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54921,6 +54921,7 @@ "dependencies": { "@types/diff": "7.0.2", "@wordpress/block-editor": "file:../block-editor", + "@wordpress/data": "file:../data", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", From 77cad8f6b20c45d4170e46b7d12fe0c475a4ba42 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 15:23:49 +1100 Subject: [PATCH 08/30] Add more comments and simplify the user handling --- .../sync/src/awareness/awareness-manager.ts | 17 +++++++----- .../sync/src/awareness/awareness-state.ts | 26 +++++++++---------- packages/sync/src/manager.ts | 6 +---- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index e9b979e384a2d1..f8274769d6365d 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -6,7 +6,7 @@ import type * as Y from 'yjs'; /** * Internal dependencies */ -import type { ObjectID, ObjectType } from '../types'; +import type { ObjectID, ObjectType, RecordHandlers } from '../types'; import type { AwarenessState } from './awareness-state'; import { PostEditorAwarenessState } from './post-editor-awareness-state'; import type { UserInfo, WordPressUserInfo } from './awareness-types'; @@ -73,22 +73,25 @@ export function getPostEditorAwareness( /** * Create an awareness instance for the given object type and object ID. - * @param objectType Object type. - * @param objectId Object ID. - * @param ydoc Yjs document. - * @param currentUser Current user. + * @param objectType Object type. + * @param objectId Object ID. + * @param ydoc Yjs document. + * @param recordHandlers Record handlers. * @return Awareness instance. */ export async function createAwareness( objectType: ObjectType, objectId: ObjectID | null, ydoc: Y.Doc, - currentUser: WordPressUserInfo + recordHandlers: RecordHandlers ): Promise< AwarenessState | undefined > { if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); + + const currentUser = await recordHandlers.getCurrentUser(); const userInfo = getUserInfo( awareness, currentUser ); - awareness.setUp( userInfo ); + + awareness.setUp( recordHandlers, userInfo ); awarenessInstances.set( getAwarenessId( objectType, objectId ), awareness diff --git a/packages/sync/src/awareness/awareness-state.ts b/packages/sync/src/awareness/awareness-state.ts index 848208db6be4a9..923a2aabf3947d 100644 --- a/packages/sync/src/awareness/awareness-state.ts +++ b/packages/sync/src/awareness/awareness-state.ts @@ -30,8 +30,8 @@ abstract class AwarenessWithEqualityChecks< * trigger rerenders of any subscribed components. * * Equality checks are provided by the abstract `equalityFieldChecks` property. - * @param field - * @param value + * @param field - The field to set. + * @param value - The value to set. */ public setLocalStateField< FieldName extends string & keyof State >( field: FieldName, @@ -64,9 +64,9 @@ abstract class AwarenessWithEqualityChecks< /** * Determine if a field value has changed using the provided equality checks. - * @param field - * @param value1 - * @param value2 + * @param field - The field to check. + * @param value1 - The first value to compare. + * @param value2 - The second value to compare. */ protected isFieldEqual< FieldName extends keyof State >( field: FieldName, @@ -92,8 +92,8 @@ abstract class AwarenessWithEqualityChecks< /** * Determine if two states are equal by comparing each field using the * provided equality checks. - * @param state1 - * @param state2 + * @param state1 - The first state to compare. + * @param state2 - The second state to compare. */ protected isStateEqual( state1: State, state2: State ): boolean { return [ @@ -190,7 +190,7 @@ export abstract class AwarenessState< /** * Allow external code to subscribe to awareness state changes. - * @param callback + * @param callback - The callback to subscribe to. */ public onStateChange( callback: ( newState: EnhancedState< State >[] ) => void @@ -207,9 +207,9 @@ export abstract class AwarenessState< /** * Set a local state field on an awareness document with throttle. See caveats * of this.setLocalStateField. - * @param field - * @param value - * @param wait + * @param field - The field to set. + * @param value - The value to set. + * @param wait - The wait time in milliseconds. */ public setThrottledLocalStateField< FieldName extends string & keyof State, @@ -234,7 +234,7 @@ export abstract class AwarenessState< /** * Set the current user's connection status as awareness state. - * @param isConnected + * @param isConnected - The connection status. */ public setConnectionStatus( isConnected: boolean ): void { if ( isConnected ) { @@ -248,7 +248,7 @@ export abstract class AwarenessState< /** * Update all subscribed listeners with the latest awareness state. - * @param forceUpdate + * @param forceUpdate - Whether to force an update. */ protected updateSubscribers( forceUpdate = false ): void { if ( ! this.stateSubscriptions.length ) { diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 4f856aad1ca66a..cb992c9f77b641 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -174,16 +174,12 @@ export function createSyncManager(): SyncManager { entityStates.set( entityId, entityState ); - // Get the current user from the handlers. - const currentUser = await handlers.getCurrentUser(); - // Create awareness for the given entity and its Yjs document. const awareness = await createAwareness( objectType, objectId, ydoc, - handlers, - currentUser + handlers ); // Create providers for the given entity and its Yjs document. From 0e12107399cc42c0b7e5def2a9273724de647df3 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 15:54:29 +1100 Subject: [PATCH 09/30] Tweak the exported functions --- packages/sync/README.md | 10 ---------- packages/sync/src/awareness/awareness-manager.ts | 4 ++++ packages/sync/src/index.ts | 1 - 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/sync/README.md b/packages/sync/README.md index 8828c965b2db6e..2dd2b37cc4056c 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -50,16 +50,6 @@ Origin string for CRDT document changes originating from the local editor. Origin string for CRDT document changes originating from the sync manager. -### setConnectionStatus - -Set the current user's connection status in the awareness instance for the given object type and object ID. - -_Parameters_ - -- _objectType_ `ObjectType`: Object type. -- _objectId_ `ObjectID | null`: Object ID. -- _isConnected_ `boolean`: Connection status. - ### WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE WordPress meta key used to persist the CRDT document for an entity. diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index f8274769d6365d..986ac2b1a2cb53 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -88,6 +88,7 @@ export async function createAwareness( if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); + // TODO: Is there still a need to memoize the current user? const currentUser = await recordHandlers.getCurrentUser(); const userInfo = getUserInfo( awareness, currentUser ); @@ -104,6 +105,9 @@ export async function createAwareness( /** * Set the current user's connection status in the awareness instance for the given object type and object ID. + * + * TODO: Use this in a generic way with each provider so it doesn't need to be exported externally. + * * @param objectType Object type. * @param objectId Object ID. * @param isConnected Connection status. diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 17d0c4444fb78a..13f9875c021c4c 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -28,4 +28,3 @@ export { } from './config'; export { createSyncManager } from './manager'; export type * from './types'; -export { setConnectionStatus } from './awareness/awareness-manager'; From 1b19ef1fc28fe6ea87c3788c39dbaebfccf5cc0e Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 15:58:12 +1100 Subject: [PATCH 10/30] Revert the webpack workaround --- packages/dependency-extraction-webpack-plugin/lib/util.js | 1 + packages/sync/package.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index ce420116404a29..5a5d88b8006ff6 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -5,6 +5,7 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', + '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', diff --git a/packages/sync/package.json b/packages/sync/package.json index 532b91b111bc4a..72a63f8605a302 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -40,7 +40,6 @@ "./package.json": "./package.json" }, "react-native": "src/index", - "wpScript": true, "types": "build-types", "sideEffects": false, "dependencies": { From 215942cb19c8443103005341ef849628f25ad2ab Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 16:14:13 +1100 Subject: [PATCH 11/30] Fix the type error --- packages/sync/src/awareness/post-editor-awareness-state.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index 8f27466ef780c6..62ae8114a53153 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -8,8 +8,6 @@ import type * as Y from 'yjs'; */ import { store as blockEditorStore } from '@wordpress/block-editor'; import { select, subscribe } from '@wordpress/data'; -// @ts-expect-error No exported types for block editor store selectors. -import { type BlockEditorStoreSelectors } from '@wordpress/block-editor/build-types/store/selectors'; /** * Internal dependencies @@ -51,7 +49,7 @@ export class PostEditorAwarenessState extends AwarenessState< PostEditorState > getSelectionStart, getSelectionEnd, getSelectedBlocksInitialCaretPosition, - } = select( blockEditorStore ) as BlockEditorStoreSelectors; + } = select( blockEditorStore ); // Keep track of the current selection in the outer scope so we can compare // in the subscription. From 4f37149063f6d259c571761b71157ae3f64328da Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Mon, 19 Jan 2026 21:16:33 +1100 Subject: [PATCH 12/30] ignore types for block editor import --- packages/sync/src/awareness/post-editor-awareness-state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index 62ae8114a53153..9b11fe04b06b71 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -6,6 +6,7 @@ import type * as Y from 'yjs'; /** * WordPress dependencies */ +// @ts-ignore No exported types for block editor store selectors. import { store as blockEditorStore } from '@wordpress/block-editor'; import { select, subscribe } from '@wordpress/data'; From e9cc4bc27ae5369f96628d6758118cd4b37f0d13 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Tue, 20 Jan 2026 10:50:35 +1100 Subject: [PATCH 13/30] Fix the typo in the constant --- packages/sync/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index 67bb5cf108e07a..17f65d080cebbf 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -67,7 +67,7 @@ export const REMOVAL_DELAY_IN_MS = 5000; /** * Delay in milliseconds before updating the cursor position. */ -export const LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS = 500; +export const LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS = 5; /** * Delay in milliseconds before throttling the cursor position updates. From 63a11d7f546cee54ab5f41ea161db76648e193c1 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Tue, 20 Jan 2026 10:55:05 +1100 Subject: [PATCH 14/30] Tweaked the local storage key --- packages/sync/src/user-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sync/src/user-utils.ts b/packages/sync/src/user-utils.ts index 51a059a87c58ca..97543d08fbe48a 100644 --- a/packages/sync/src/user-utils.ts +++ b/packages/sync/src/user-utils.ts @@ -15,7 +15,7 @@ const COLOR_PALETTE = [ '#37C5F0', // cyan ]; -const LOCAL_STORAGE_KEY = 'gutenberg-rtc-preferred-color'; +const LOCAL_STORAGE_KEY = 'GUTENBERG_PREFERRED_COLOR_KEY'; /** * Generate a random integer between min and max, inclusive. From 71c7741fedb87f50757319bd24e5a21fa4fff85d Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Tue, 20 Jan 2026 11:07:17 +1100 Subject: [PATCH 15/30] Attempting to solve the test failures --- packages/core-data/src/test/resolvers.js | 2 ++ packages/core-data/src/utils/test/crdt-blocks.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 60e56079ee9a7c..f1e66c946bd7d2 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -172,6 +172,7 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), + getCurrentUser: expect.any( Function ), } ); } ); @@ -225,6 +226,7 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), + getCurrentUser: expect.any( Function ), } ); } ); diff --git a/packages/core-data/src/utils/test/crdt-blocks.ts b/packages/core-data/src/utils/test/crdt-blocks.ts index 27cdf7f8fd731f..bc4b91e7594eac 100644 --- a/packages/core-data/src/utils/test/crdt-blocks.ts +++ b/packages/core-data/src/utils/test/crdt-blocks.ts @@ -6,7 +6,14 @@ import { Y } from '@wordpress/sync'; /** * External dependencies */ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { + describe, + expect, + it, + jest, + beforeEach, + afterEach, +} from '@jest/globals'; /** * Mock uuid module From f6a7cc0062c991b66130f373bd72be9b3e0480e6 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Tue, 20 Jan 2026 11:36:57 +1100 Subject: [PATCH 16/30] Fix the test fialures --- packages/core-data/src/utils/test/crdt-blocks.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/utils/test/crdt-blocks.ts b/packages/core-data/src/utils/test/crdt-blocks.ts index bc4b91e7594eac..f03453c6582bac 100644 --- a/packages/core-data/src/utils/test/crdt-blocks.ts +++ b/packages/core-data/src/utils/test/crdt-blocks.ts @@ -19,19 +19,26 @@ import { * Mock uuid module */ jest.mock( 'uuid', () => ( { - v4: jest.fn( () => 'mocked-uuid-' + Math.random() ), + v4: () => 'mocked-uuid-' + Math.random(), } ) ); /** * Mock @wordpress/blocks module */ jest.mock( '@wordpress/blocks', () => ( { - getBlockTypes: jest.fn( () => [ + getBlockTypes: () => [ { name: 'core/paragraph', attributes: { content: { type: 'rich-text' } }, }, - ] ), + ], +} ) ); + +/** + * Mock @wordpress/block-editor to avoid private-apis unlock errors + */ +jest.mock( '@wordpress/block-editor', () => ( { + store: {}, } ) ); /** From 9200272507dcdfb04a2d72d70ccb0e73dd1fed47 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 21 Jan 2026 07:43:57 +1100 Subject: [PATCH 17/30] Remove a TODO --- packages/sync/src/awareness/awareness-manager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 986ac2b1a2cb53..33ef7f48679caf 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -88,7 +88,6 @@ export async function createAwareness( if ( objectId && objectType.startsWith( 'postType/' ) ) { const awareness = new PostEditorAwarenessState( ydoc ); - // TODO: Is there still a need to memoize the current user? const currentUser = await recordHandlers.getCurrentUser(); const userInfo = getUserInfo( awareness, currentUser ); From a2f7a09e82f841a4057dac71813a121beeec4aca Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 21 Jan 2026 15:56:39 +1100 Subject: [PATCH 18/30] Re-wrote the user selection to be in the core-data, and move the awareness instance management to sync via getSyncanager --- package-lock.json | 3 +- packages/core-data/package.json | 1 + packages/core-data/src/resolvers.js | 19 +- .../src/utils/crdt-user-selections.ts | 377 ++++++++++++++++++ packages/core-data/tsconfig.json | 3 +- packages/sync/README.md | 4 + packages/sync/package.json | 2 - .../sync/src/awareness/awareness-manager.ts | 60 --- .../awareness/post-editor-awareness-state.ts | 114 +----- packages/sync/src/index.ts | 1 + packages/sync/src/manager.ts | 47 ++- packages/sync/src/selection-utils.ts | 221 ++-------- packages/sync/src/test/manager.ts | 1 + packages/sync/src/types.ts | 5 + packages/sync/tsconfig.json | 2 - 15 files changed, 498 insertions(+), 362 deletions(-) create mode 100644 packages/core-data/src/utils/crdt-user-selections.ts diff --git a/package-lock.json b/package-lock.json index 0b15680424cd22..16ae2a12d280ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52648,6 +52648,7 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@types/node": "^20.17.10", "deep-freeze": "0.0.1" }, "engines": { @@ -54918,8 +54919,6 @@ "license": "GPL-2.0-or-later", "dependencies": { "@types/diff": "7.0.2", - "@wordpress/block-editor": "file:../block-editor", - "@wordpress/data": "file:../data", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index d69644da3a2c42..cb61cd4861e0df 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -72,6 +72,7 @@ "uuid": "^9.0.1" }, "devDependencies": { + "@types/node": "^20.17.10", "deep-freeze": "0.0.1" }, "peerDependencies": { diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index c990c2ad226f72..0d68f5166e23a2 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -26,6 +26,7 @@ import { isNumericID, } from './utils'; import { fetchBlockPatterns } from './fetch'; +import { subscribeToUserSelectionChanges } from './utils/crdt-user-selections'; /** * Requests authors from the REST API. @@ -234,8 +235,22 @@ export const getEntityRecord = ); }, // Get the current user. - getCurrentUser: async () => - await resolveSelect.getCurrentUser(), + getCurrentUser: async () => { + await resolveSelect.getCurrentUser(); + }, + // Subscribe to user selection changes. + subscribeToUserSelectionChanges: ( + yDoc, + setSelectionState + ) => { + subscribeToUserSelectionChanges( + kind, + name, + key, + yDoc, + setSelectionState + ); + }, } ); } diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts new file mode 100644 index 00000000000000..f2cad5437089e8 --- /dev/null +++ b/packages/core-data/src/utils/crdt-user-selections.ts @@ -0,0 +1,377 @@ +/** + * WordPress dependencies + */ +import { dispatch, select, subscribe } from '@wordpress/data'; +import { + Y, + LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, + CRDT_RECORD_MAP_KEY, +} from '@wordpress/sync'; +// @ts-ignore No exported types for block editor store selectors. +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Internal dependencies + */ +import { store as coreStore } from '../'; +import { type WPBlockSelection } from '../types'; + +/** + * Convenience types to manage block values with a clientId, attributes, and innerBlocks. + */ +type BlockClientId = string; +type BlockInnerBlocks = Y.Array< SelectableBlock >; +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; + +/** + * Subscribe to user selection changes and update the selection state. + * + * @param kind - The kind of entity. + * @param name - The name of the entity. + * @param recordId - The ID of the entity. + * @param yDoc - Y.Doc + * @param setSelectionState - The function to set the selection state. + */ +export function subscribeToUserSelectionChanges( + kind: string, + name: string, + recordId: string | number, + yDoc: Y.Doc, + setSelectionState: ( selectionState: SelectionState ) => void +): 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 updateSelectionInEntityRecord( + kind, + name, + recordId, + 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, + yDoc + ); + setSelectionState( selectionState ); + }, LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS ); + } ); +} + +/** + * Converts WordPress block editor selection to a SelectionState. + * + * @param selectionStart - The start position of the selection + * @param selectionEnd - The end position of the selection + * @param yDoc - The Yjs document + * @return The SelectionState + */ +function getSelectionState( + selectionStart: WPBlockSelection, + selectionEnd: WPBlockSelection, + yDoc: Y.Doc +): SelectionState { + const ydoc = yDoc.getMap( CRDT_RECORD_MAP_KEY ); + const yBlocks = ydoc.get( 'blocks' ) as Y.Array< SelectableBlock >; + + const isSelectionEmpty = Object.keys( selectionStart ).length === 0; + const noSelection: SelectionNone = { + type: SelectionType.None, + }; + + if ( isSelectionEmpty ) { + // Case 1: No selection + return noSelection; + } + + // When the page initially loads, selectionStart can contain an empty object `{}`. + const isSelectionInOneBlock = + selectionStart.clientId === selectionEnd.clientId; + const isCursorOnly = + isSelectionInOneBlock && selectionStart.offset === selectionEnd.offset; + const isSelectionAWholeBlock = + isSelectionInOneBlock && + selectionStart.offset === undefined && + selectionEnd.offset === undefined; + + if ( isSelectionAWholeBlock ) { + // Case 2: A whole block is selected. + return { + type: SelectionType.WholeBlock, + blockId: selectionStart.clientId, + }; + } else if ( isCursorOnly ) { + // Case 3: Cursor only, no text selected + const cursorPosition = getCursorPosition( selectionStart, yBlocks ); + + if ( ! cursorPosition ) { + // If we can't find the cursor position in block text, treat it as a non-selection. + return noSelection; + } + + return { + type: SelectionType.Cursor, + blockId: selectionStart.clientId, + cursorPosition, + }; + } else if ( isSelectionInOneBlock ) { + // Case 4: Selection in a single block + const cursorStartPosition = getCursorPosition( + selectionStart, + yBlocks + ); + const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks ); + + if ( ! cursorStartPosition || ! cursorEndPosition ) { + // If we can't find the cursor positions in block text, treat it as a non-selection. + return noSelection; + } + + return { + type: SelectionType.SelectionInOneBlock, + blockId: selectionStart.clientId, + cursorStartPosition, + cursorEndPosition, + }; + } + + // Caes 5: Selection in multiple blocks + const cursorStartPosition = getCursorPosition( selectionStart, yBlocks ); + const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks ); + if ( ! cursorStartPosition || ! cursorEndPosition ) { + // If we can't find the cursor positions in block text, treat it as a non-selection. + return noSelection; + } + + return { + type: SelectionType.SelectionInMultipleBlocks, + blockStartId: selectionStart.clientId, + blockEndId: selectionEnd.clientId, + cursorStartPosition, + cursorEndPosition, + }; +} + +/** + * Update the entity record with the current user's selection. + * + * @param kind - The kind of entity. + * @param name - The name of the entity. + * @param recordId - The ID of the entity. + * @param selectionStart - The start position of the selection. + * @param selectionEnd - The end position of the selection. + * @param initialPosition - The initial position of the selection. + */ +export async function updateSelectionInEntityRecord( + kind: string, + name: string, + recordId: string | number, + selectionStart: WPBlockSelection, + selectionEnd: WPBlockSelection, + initialPosition: number | null +): Promise< void > { + if ( ! selectionStart.clientId ) { + return; + } + + // 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, + }; + const { editEntityRecord } = dispatch( coreStore ); + + editEntityRecord( kind, name, recordId, edits, options ); +} + +/** + * Get the cursor position from a selection. + * + * @param selection - The selection. + * @param blocks - The blocks to search through. + * @return The cursor position, or null if not found. + */ +function getCursorPosition( + selection: WPBlockSelection, + blocks: Y.Array< SelectableBlock > +): CursorPosition | null { + const block = findBlockByClientId( selection.clientId, blocks ); + if ( ! block ) { + return null; + } + + const attributes = block.get( 'attributes' ) as Y.Map< Y.Text >; + const currentYText = attributes.get( selection.attributeKey ) as Y.Text; + + const relativePosition = Y.createRelativePositionFromTypeIndex( + currentYText, + selection.offset + ); + + return { + relativePosition, + absoluteOffset: selection.offset, + }; +} + +/** + * Find a block by its client ID. + * + * @param blockId - The client ID of the block. + * @param blocks - The blocks to search through. + * @return The block if found, null otherwise. + */ +function findBlockByClientId( + blockId: string, + blocks: Y.Array< SelectableBlock > +): SelectableBlock | null { + for ( const block of blocks ) { + if ( block.get( 'clientId' ) === blockId ) { + return block; + } + + const innerBlocks = block.get( 'innerBlocks' ) as BlockInnerBlocks; + + if ( innerBlocks.length > 0 ) { + const innerBlock = findBlockByClientId( + blockId, + block.get( 'innerBlocks' ) as Y.Array< SelectableBlock > + ); + + if ( innerBlock ) { + return innerBlock; + } + } + } + + return null; +} diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index 57c9d208e4c689..f392810314f200 100644 --- a/packages/core-data/tsconfig.json +++ b/packages/core-data/tsconfig.json @@ -3,7 +3,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "checkJs": false, - "noImplicitAny": false + "noImplicitAny": false, + "types": [ "node" ] }, "references": [ { "path": "../api-fetch" }, diff --git a/packages/sync/README.md b/packages/sync/README.md index 2dd2b37cc4056c..4041ab010d5772 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -42,6 +42,10 @@ The sync manager orchestrates the lifecycle of syncing entity records. It create Deltas are used to calculate incremental Y.Text updates. +### LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS + +Delay in milliseconds before updating the cursor position. + ### LOCAL_EDITOR_ORIGIN Origin string for CRDT document changes originating from the local editor. diff --git a/packages/sync/package.json b/packages/sync/package.json index 72a63f8605a302..592cc5a86c1d4c 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -44,8 +44,6 @@ "sideEffects": false, "dependencies": { "@types/diff": "7.0.2", - "@wordpress/block-editor": "file:../block-editor", - "@wordpress/data": "file:../data", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts index 33ef7f48679caf..625b61b6d64195 100644 --- a/packages/sync/src/awareness/awareness-manager.ts +++ b/packages/sync/src/awareness/awareness-manager.ts @@ -12,22 +12,6 @@ import { PostEditorAwarenessState } from './post-editor-awareness-state'; import type { UserInfo, WordPressUserInfo } from './awareness-types'; import { getBrowserName, getNewUserColor } from '../user-utils'; -const awarenessInstances: Map< string, AwarenessState > = new Map(); - -function getAwarenessId( - objectType: ObjectType, - objectId: ObjectID | null -): string { - return `${ objectType }:${ objectId }`; -} - -function getAwarenessInstance( - objectType: ObjectType, - objectId: ObjectID | null -): AwarenessState | undefined { - return awarenessInstances.get( getAwarenessId( objectType, objectId ) ); -} - function getUserInfo( awareness: AwarenessState, wpUser: WordPressUserInfo @@ -50,27 +34,6 @@ function getUserInfo( }; } -/** - * Get the post editor awareness instance for the given post ID and post type. - * @param postId Post ID. - * @param postType Post type. - * @return Post editor awareness instance. - */ -export function getPostEditorAwareness( - postId: number, - postType: string -): PostEditorAwarenessState | undefined { - const objectId: ObjectID = postId.toString(); - const objectType: ObjectType = `postType/${ postType }`; - - const awareness = getAwarenessInstance( objectType, objectId ); - if ( awareness instanceof PostEditorAwarenessState ) { - return awareness; - } - - return undefined; -} - /** * Create an awareness instance for the given object type and object ID. * @param objectType Object type. @@ -92,31 +55,8 @@ export async function createAwareness( const userInfo = getUserInfo( awareness, currentUser ); awareness.setUp( recordHandlers, userInfo ); - awarenessInstances.set( - getAwarenessId( objectType, objectId ), - awareness - ); return awareness; } return undefined; } - -/** - * Set the current user's connection status in the awareness instance for the given object type and object ID. - * - * TODO: Use this in a generic way with each provider so it doesn't need to be exported externally. - * - * @param objectType Object type. - * @param objectId Object ID. - * @param isConnected Connection status. - */ -export function setConnectionStatus( - objectType: ObjectType, - objectId: ObjectID | null, - isConnected: boolean -): void { - getAwarenessInstance( objectType, objectId )?.setConnectionStatus( - isConnected - ); -} diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts index 9b11fe04b06b71..8e6e551d0ebbd1 100644 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ b/packages/sync/src/awareness/post-editor-awareness-state.ts @@ -1,37 +1,12 @@ -/** - * External dependencies - */ -import type * as Y from 'yjs'; - -/** - * WordPress dependencies - */ -// @ts-ignore No exported types for block editor store selectors. -import { store as blockEditorStore } from '@wordpress/block-editor'; -import { select, subscribe } from '@wordpress/data'; - /** * Internal dependencies */ -import type { - PostEditorState, - UserInfo, - WPBlockSelection, -} from './awareness-types'; +import type { PostEditorState, UserInfo } from './awareness-types'; import type { RecordHandlers } from '../types'; import { AwarenessState } from './awareness-state'; import { areUserInfosEqual } from '../user-utils'; -import { - LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, - AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS, - CRDT_RECORD_MAP_KEY, -} from '../config'; -import type { SelectableBlock } from '../selection-utils'; -import { - updateSelectionInEntityRecord, - getSelectionState, - areEditorStatesEqual, -} from '../selection-utils'; +import { AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS } from '../config'; +import { areEditorStatesEqual } from '../selection-utils'; export class PostEditorAwarenessState extends AwarenessState< PostEditorState > { protected equalityFieldChecks = { @@ -42,80 +17,15 @@ export class PostEditorAwarenessState extends AwarenessState< PostEditorState > public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { super.setUp( recordHandlers, userInfo ); - this.subscribeToSelectionChanges( recordHandlers ); - } - - private subscribeToSelectionChanges( handlers: 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; - - // Provided type is generic `Function`. - - 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. - void updateSelectionInEntityRecord( - handlers, - selectionStart, - selectionEnd, - getSelectedBlocksInitialCaretPosition() - ); - - // 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( () => { - this.updateSelectionState( selectionStart, selectionEnd ); - }, LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS ); - } ); - } - - private updateSelectionState( - selectionStart: WPBlockSelection, - selectionEnd: WPBlockSelection - ): void { - const ydoc = this.doc.getMap( CRDT_RECORD_MAP_KEY ); - const yBlocks = ydoc.get( 'blocks' ) as Y.Array< SelectableBlock >; - const selection = getSelectionState( - selectionStart, - selectionEnd, - yBlocks - ); - - // Throttle remote awareness updates. - this.setThrottledLocalStateField( - 'editorState', - { selection }, - AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS + // Subscribe to user selection changes. + recordHandlers.subscribeToUserSelectionChanges( + this.doc, + ( selectionState ) => + this.setThrottledLocalStateField( + 'editorState', + { selection: selectionState }, + AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS + ) ); } } diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 13f9875c021c4c..6180aaa8e754e3 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -25,6 +25,7 @@ export { LOCAL_EDITOR_ORIGIN, LOCAL_SYNC_MANAGER_ORIGIN, WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE, + LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, } from './config'; export { createSyncManager } from './manager'; export type * from './types'; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 0975b1257b63bf..5d4b7bd08aabc5 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -29,6 +29,10 @@ import type { import { createUndoManager } from './undo-manager'; import { createYjsDoc } from './utils'; import { createAwareness } from './awareness/awareness-manager'; +import type { AwarenessState } from './awareness/awareness-state'; + +const AWARENESS_INSTANCE_SEPARATOR = ':'; +const ENTITY_INSTANCE_SEPARATOR = '_'; interface EntityState { handlers: RecordHandlers; @@ -46,6 +50,7 @@ interface EntityState { */ export function createSyncManager(): SyncManager { const entityStates: Map< EntityID, EntityState > = new Map(); + const awarenessInstances: Map< string, AwarenessState > = new Map(); /** * A "sync-aware" undo manager for all synced entities. It is lazily created @@ -106,6 +111,8 @@ export function createSyncManager(): SyncManager { return; // Already bootstrapped. } + const awarenessId = getAwarenessId( objectType, objectId ); + const ydoc = createYjsDoc( { objectType } ); const recordMap = ydoc.getMap( RECORD_KEY ); const recordMetaMap = ydoc.getMap( RECORD_METADATA_KEY ); @@ -117,6 +124,7 @@ export function createSyncManager(): SyncManager { recordMap.unobserveDeep( onRecordUpdate ); ydoc.destroy(); entityStates.delete( entityId ); + awarenessInstances.delete( awarenessId ); }; // When the CRDT document is updated by an UndoManager or a connection (not @@ -182,6 +190,11 @@ export function createSyncManager(): SyncManager { handlers ); + // Awareness can be undefined, if the object type is not supported. + if ( awareness ) { + awarenessInstances.set( awarenessId, awareness ); + } + // Create providers for the given entity and its Yjs document. const providerResults = await Promise.all( providerCreators.map( ( create ) => @@ -217,7 +230,39 @@ export function createSyncManager(): SyncManager { objectType: ObjectType, objectId: ObjectID ): EntityID { - return `${ objectType }_${ objectId }`; + return getInstanceId( objectType, objectId, ENTITY_INSTANCE_SEPARATOR ); + } + + /** + * Get the awareness ID for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + */ + function getAwarenessId( + objectType: ObjectType, + objectId: ObjectID + ): string { + return getInstanceId( + objectType, + objectId, + AWARENESS_INSTANCE_SEPARATOR + ); + } + + /** + * Get the instance ID for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {string} separator Separator between object type and object ID. + */ + function getInstanceId( + objectType: ObjectType, + objectId: ObjectID, + separator: string + ): string { + return `${ objectType }${ separator }${ objectId }`; } /** diff --git a/packages/sync/src/selection-utils.ts b/packages/sync/src/selection-utils.ts index 51a70a73fa5d62..520fc7b8335597 100644 --- a/packages/sync/src/selection-utils.ts +++ b/packages/sync/src/selection-utils.ts @@ -1,14 +1,12 @@ /** * External dependencies */ -import * as Y from 'yjs'; +import type * as Y from 'yjs'; /** * Internal dependencies */ import type { EditorState } from './awareness/awareness-types'; -import { type WPBlockSelection } from './awareness/awareness-types'; -import type { RecordHandlers } from './types'; /** * Convenience types to manage block values with a clientId, attributes, and innerBlocks. @@ -97,183 +95,13 @@ export type SelectionState = | SelectionWholeBlock; /** - * Converts WordPress block editor selection to a SelectionState. + * Check if two selection states are equal. * - * @param selectionStart - The start position of the selection - * @param selectionEnd - The end position of the selection - * @param yBlocks - * @return The SelectionState + * @param selection1 - The first selection state. + * @param selection2 - The second selection state. + * @return True if the selection states are equal, false otherwise. */ -export function getSelectionState( - selectionStart: WPBlockSelection, - selectionEnd: WPBlockSelection, - yBlocks: Y.Array< SelectableBlock > -): SelectionState { - const isSelectionEmpty = Object.keys( selectionStart ).length === 0; - const noSelection: SelectionNone = { - type: SelectionType.None, - }; - - if ( isSelectionEmpty ) { - // Case 1: No selection - return noSelection; - } - - // When the page initially loads, selectionStart can contain an empty object `{}`. - const isSelectionInOneBlock = - selectionStart.clientId === selectionEnd.clientId; - const isCursorOnly = - isSelectionInOneBlock && selectionStart.offset === selectionEnd.offset; - const isSelectionAWholeBlock = - isSelectionInOneBlock && - selectionStart.offset === undefined && - selectionEnd.offset === undefined; - - if ( isSelectionAWholeBlock ) { - // Case 2: A whole block is selected. - return { - type: SelectionType.WholeBlock, - blockId: selectionStart.clientId, - }; - } else if ( isCursorOnly ) { - // Case 3: Cursor only, no text selected - const cursorPosition = getCursorPosition( selectionStart, yBlocks ); - - if ( ! cursorPosition ) { - // If we can't find the cursor position in block text, treat it as a non-selection. - return noSelection; - } - - return { - type: SelectionType.Cursor, - blockId: selectionStart.clientId, - cursorPosition, - }; - } else if ( isSelectionInOneBlock ) { - // Case 4: Selection in a single block - const cursorStartPosition = getCursorPosition( - selectionStart, - yBlocks - ); - const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks ); - - if ( ! cursorStartPosition || ! cursorEndPosition ) { - // If we can't find the cursor positions in block text, treat it as a non-selection. - return noSelection; - } - - return { - type: SelectionType.SelectionInOneBlock, - blockId: selectionStart.clientId, - cursorStartPosition, - cursorEndPosition, - }; - } - - // Caes 5: Selection in multiple blocks - const cursorStartPosition = getCursorPosition( selectionStart, yBlocks ); - const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks ); - if ( ! cursorStartPosition || ! cursorEndPosition ) { - // If we can't find the cursor positions in block text, treat it as a non-selection. - return noSelection; - } - - return { - type: SelectionType.SelectionInMultipleBlocks, - blockStartId: selectionStart.clientId, - blockEndId: selectionEnd.clientId, - cursorStartPosition, - cursorEndPosition, - }; -} - -/** - * Update the entity record with the current user's selection. - * - * @param handlers - Record handlers. - * @param selectionStart - The start position of the selection. - * @param selectionEnd - The end position of the selection. - * @param initialPosition - The initial position of the selection. - */ -export async function updateSelectionInEntityRecord( - handlers: RecordHandlers, - selectionStart: WPBlockSelection, - selectionEnd: WPBlockSelection, - initialPosition: number | null -): Promise< void > { - if ( ! selectionStart.clientId ) { - return; - } - - // 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, - }; - - handlers.editRecord( edits, options ); -} - -export function getCursorPosition( - selection: WPBlockSelection, - blocks: Y.Array< SelectableBlock > -): CursorPosition | null { - const block = findBlockByClientId( selection.clientId, blocks ); - if ( ! block ) { - return null; - } - - const attributes = block.get( 'attributes' ) as Y.Map< Y.Text >; - const currentYText = attributes.get( selection.attributeKey ) as Y.Text; - - const relativePosition = Y.createRelativePositionFromTypeIndex( - currentYText, - selection.offset - ); - - return { - relativePosition, - absoluteOffset: selection.offset, - }; -} - -function findBlockByClientId( - blockId: string, - blocks: Y.Array< SelectableBlock > -): SelectableBlock | null { - for ( const block of blocks ) { - if ( block.get( 'clientId' ) === blockId ) { - return block; - } - - const innerBlocks = block.get( 'innerBlocks' ) as BlockInnerBlocks; - - if ( innerBlocks.length > 0 ) { - const innerBlock = findBlockByClientId( - blockId, - block.get( 'innerBlocks' ) as Y.Array< SelectableBlock > - ); - - if ( innerBlock ) { - return innerBlock; - } - } - } - - return null; -} - -export function areSelectionsEqual( +function areSelectionsEqual( selection1: SelectionState, selection2: SelectionState ): boolean { @@ -333,11 +161,35 @@ export function areSelectionsEqual( ); default: - //logger.error( 'Unable to compare selection types:', selection1, selection2 ); return false; } } +/** + * 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. + */ +export function areEditorStatesEqual( + state1?: EditorState, + state2?: EditorState +): boolean { + if ( ! state1 || ! state2 ) { + return state1 === state2; + } + + return areSelectionsEqual( state1.selection, state2.selection ); +} + +/** + * Check if two cursor positions are equal. + * + * @param cursorPosition1 - The first cursor position. + * @param cursorPosition2 - The second cursor position. + * @return True if the cursor positions are equal, false otherwise. + */ function areCursorPositionsEqual( cursorPosition1: CursorPosition, cursorPosition2: CursorPosition @@ -353,14 +205,3 @@ function areCursorPositionsEqual( return isRelativePositionEqual && isAbsoluteOffsetEqual; } - -export function areEditorStatesEqual( - state1?: EditorState, - state2?: EditorState -): boolean { - if ( ! state1 || ! state2 ) { - return state1 === state2; - } - - return areSelectionsEqual( state1.selection, state2.selection ); -} diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index f0a5ca0d42008f..a16b64ecc06234 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -108,6 +108,7 @@ describe( 'SyncManager', () => { getCurrentUser: jest.fn( async () => Promise.resolve( mockCurrentUser ) ), + subscribeToUserSelectionChanges: jest.fn(), }; } ); diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index c815e817705db5..b1cf0ce3660f01 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -14,6 +14,7 @@ import type { Awareness } from 'y-protocols/awareness'; */ import type { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from './config'; import type { WordPressUserInfo } from './awareness/awareness-types'; +import type { SelectionState } from './selection-utils'; /* globalThis */ declare global { @@ -70,6 +71,10 @@ export interface RecordHandlers { refetchRecord: () => Promise< void >; saveRecord: () => Promise< void >; getCurrentUser: () => Promise< WordPressUserInfo >; + subscribeToUserSelectionChanges: ( + yDoc: Y.Doc, + setSelectionState: ( selectionState: SelectionState ) => void + ) => void; } export interface SyncConfig { diff --git a/packages/sync/tsconfig.json b/packages/sync/tsconfig.json index b8d226845cb662..3dee2350432214 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -6,8 +6,6 @@ }, "exclude": [ "src/y-utilities/y-multidoc-undomanager.js" ], "references": [ - { "path": "../block-editor" }, - { "path": "../data" }, { "path": "../hooks" }, { "path": "../undo-manager" }, { "path": "../url" } From d1066e9b1ac282b78bfccdbe7f82bb1574d394a6 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 21 Jan 2026 16:10:52 +1100 Subject: [PATCH 19/30] Clean up the code --- .../sync/src/awareness/awareness-types.ts | 22 ------------- packages/sync/src/manager.ts | 31 +++---------------- packages/sync/src/types.ts | 1 + 3 files changed, 6 insertions(+), 48 deletions(-) diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index ce041c73549327..3ed2ea929d9500 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -82,28 +82,6 @@ export type EnhancedState< State extends BaseState > = State & { isMe: boolean; }; -/** - * A block selection object. - * - * In order to avoid circular dependencies, we define it here instead of importing - * the WPBlockSelection interface from @wordpress/editor. - */ -export type WPBlockSelection = { - /** - * A block client ID. - */ - clientId: string; - /** - * A block attribute key. - */ - attributeKey: string; - /** - * An attribute value offset, based on the rich - * text value. See `wp.richText.create`. - */ - offset: number; -}; - export type EqualityFieldCheck< State extends BaseState, FieldName extends keyof State, diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index 5d4b7bd08aabc5..c25f28b746245b 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -25,15 +25,13 @@ import type { SyncConfig, SyncManager, SyncUndoManager, + AwarenessID, } from './types'; import { createUndoManager } from './undo-manager'; import { createYjsDoc } from './utils'; import { createAwareness } from './awareness/awareness-manager'; import type { AwarenessState } from './awareness/awareness-state'; -const AWARENESS_INSTANCE_SEPARATOR = ':'; -const ENTITY_INSTANCE_SEPARATOR = '_'; - interface EntityState { handlers: RecordHandlers; objectId: ObjectID; @@ -50,7 +48,7 @@ interface EntityState { */ export function createSyncManager(): SyncManager { const entityStates: Map< EntityID, EntityState > = new Map(); - const awarenessInstances: Map< string, AwarenessState > = new Map(); + const awarenessInstances: Map< AwarenessID, AwarenessState > = new Map(); /** * A "sync-aware" undo manager for all synced entities. It is lazily created @@ -230,7 +228,7 @@ export function createSyncManager(): SyncManager { objectType: ObjectType, objectId: ObjectID ): EntityID { - return getInstanceId( objectType, objectId, ENTITY_INSTANCE_SEPARATOR ); + return `${ objectType }_${ objectId }`; } /** @@ -242,27 +240,8 @@ export function createSyncManager(): SyncManager { function getAwarenessId( objectType: ObjectType, objectId: ObjectID - ): string { - return getInstanceId( - objectType, - objectId, - AWARENESS_INSTANCE_SEPARATOR - ); - } - - /** - * Get the instance ID for the given object type and object ID. - * - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {string} separator Separator between object type and object ID. - */ - function getInstanceId( - objectType: ObjectType, - objectId: ObjectID, - separator: string - ): string { - return `${ objectType }${ separator }${ objectId }`; + ): AwarenessID { + return `${ objectType }:${ objectId }`; } /** diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index b1cf0ce3660f01..6c3a15593cee60 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -31,6 +31,7 @@ declare global { } export type CRDTDoc = Y.Doc; +export type AwarenessID = string; export type EntityID = string; export type ObjectID = string; export type ObjectType = string; From 96c6e6a0d25b9264bc8dfc9093302fffab66d775 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 21 Jan 2026 16:15:57 +1100 Subject: [PATCH 20/30] Added a todo for local storage --- packages/sync/src/local-storage.ts | 2 ++ packages/sync/src/selection-utils.ts | 14 -------------- packages/sync/src/user-utils.ts | 2 ++ 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/sync/src/local-storage.ts b/packages/sync/src/local-storage.ts index b249d1306183b1..b1465288020a90 100644 --- a/packages/sync/src/local-storage.ts +++ b/packages/sync/src/local-storage.ts @@ -1,3 +1,5 @@ +// TODO: Drop this file, and use @wordpress/preferences instead. + /** * Load data from localStorage with error handling * @param key - The localStorage key to read from diff --git a/packages/sync/src/selection-utils.ts b/packages/sync/src/selection-utils.ts index 520fc7b8335597..9ff8839cc9d012 100644 --- a/packages/sync/src/selection-utils.ts +++ b/packages/sync/src/selection-utils.ts @@ -8,20 +8,6 @@ import type * as Y from 'yjs'; */ import type { EditorState } from './awareness/awareness-types'; -/** - * Convenience types to manage block values with a clientId, attributes, and innerBlocks. - */ -type BlockClientId = string; -type BlockInnerBlocks = Y.Array< SelectableBlock >; -type BlockAttributes = Y.Map< Y.Text >; - -/** - * A block that can be selected. - */ -export type SelectableBlock = Y.Map< - BlockClientId | BlockAttributes | BlockInnerBlocks ->; - /** * The type of selection. */ diff --git a/packages/sync/src/user-utils.ts b/packages/sync/src/user-utils.ts index 97543d08fbe48a..b84bb24c3c0b48 100644 --- a/packages/sync/src/user-utils.ts +++ b/packages/sync/src/user-utils.ts @@ -40,6 +40,7 @@ export function getNewUserColor( existingColors: string[] ): string { ( color ) => ! existingColors.includes( color ) ); + // TODO: Drop this, and use @wordpress/preferences instead. const preferredColor = loadFromLocalStorage< string | null >( LOCAL_STORAGE_KEY, null @@ -59,6 +60,7 @@ export function getNewUserColor( existingColors: string[] ): string { hexColor = generateColorVariation( baseColor ); } + // TODO: Drop this, and use @wordpress/preferences instead. saveToLocalStorage( LOCAL_STORAGE_KEY, hexColor ); return hexColor; } From 40bba56f9360c9251639f8559bad5e0741c7a275 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 21 Jan 2026 16:23:09 +1100 Subject: [PATCH 21/30] Remove the block-editor fix --- packages/core-data/src/utils/test/crdt-blocks.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/core-data/src/utils/test/crdt-blocks.ts b/packages/core-data/src/utils/test/crdt-blocks.ts index f03453c6582bac..9cf9a648e19370 100644 --- a/packages/core-data/src/utils/test/crdt-blocks.ts +++ b/packages/core-data/src/utils/test/crdt-blocks.ts @@ -34,13 +34,6 @@ jest.mock( '@wordpress/blocks', () => ( { ], } ) ); -/** - * Mock @wordpress/block-editor to avoid private-apis unlock errors - */ -jest.mock( '@wordpress/block-editor', () => ( { - store: {}, -} ) ); - /** * Internal dependencies */ From 9fb1542f6a2134de8808e021ee93d186b8698140 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Wed, 21 Jan 2026 21:10:08 +1100 Subject: [PATCH 22/30] Fix the test using STORE_NAME --- packages/core-data/src/test/resolvers.js | 2 ++ packages/core-data/src/utils/crdt-user-selections.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index f1e66c946bd7d2..9fef71adfba704 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -173,6 +173,7 @@ describe( 'getEntityRecord', () => { refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), getCurrentUser: expect.any( Function ), + subscribeToUserSelectionChanges: expect.any( Function ), } ); } ); @@ -227,6 +228,7 @@ describe( 'getEntityRecord', () => { refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), getCurrentUser: expect.any( Function ), + subscribeToUserSelectionChanges: expect.any( Function ), } ); } ); diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts index f2cad5437089e8..91e5a9ed5cc884 100644 --- a/packages/core-data/src/utils/crdt-user-selections.ts +++ b/packages/core-data/src/utils/crdt-user-selections.ts @@ -13,7 +13,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies */ -import { store as coreStore } from '../'; +import { STORE_NAME } from '../name'; import { type WPBlockSelection } from '../types'; /** @@ -308,9 +308,15 @@ export async function updateSelectionInEntityRecord( const options = { undoIgnore: true, }; - const { editEntityRecord } = dispatch( coreStore ); - editEntityRecord( kind, name, recordId, edits, options ); + // @ts-ignore - Using STORE_NAME to avoid a circular dependency in the tests. + dispatch( STORE_NAME ).editEntityRecord( + kind, + name, + recordId, + edits, + options + ); } /** From 266fd2ca4487f51644788055feb7c6a54202f6ae Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 22 Jan 2026 15:37:25 +1100 Subject: [PATCH 23/30] Move awareness implementation details to core-data, and only leave the base interface behind --- packages/core-data/src/entities.js | 25 ++ .../core-data/src/post-editor-awareness.ts | 157 +++++++++ packages/core-data/src/resolvers.js | 23 +- packages/core-data/src/test/resolvers.js | 4 - packages/core-data/src/types.ts | 106 ++++++ .../src/utils/crdt-user-selections.ts | 330 ++++++------------ packages/sync/README.md | 46 +++ .../sync/src/awareness/awareness-manager.ts | 62 ---- .../sync/src/awareness/awareness-state.ts | 8 +- .../sync/src/awareness/awareness-types.ts | 22 +- .../awareness/post-editor-awareness-state.ts | 31 -- packages/sync/src/index.ts | 4 + packages/sync/src/manager.ts | 60 ++-- packages/sync/src/selection-utils.ts | 193 ---------- packages/sync/src/test/manager.ts | 69 ++-- packages/sync/src/types.ts | 19 +- packages/sync/src/user-utils.ts | 32 +- 17 files changed, 577 insertions(+), 614 deletions(-) create mode 100644 packages/core-data/src/post-editor-awareness.ts delete mode 100644 packages/sync/src/awareness/awareness-manager.ts delete mode 100644 packages/sync/src/awareness/post-editor-awareness-state.ts delete mode 100644 packages/sync/src/selection-utils.ts diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index eb8723698a60df..8bf1894b90801b 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -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 @@ -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' ]; @@ -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; + }, }; } diff --git a/packages/core-data/src/post-editor-awareness.ts b/packages/core-data/src/post-editor-awareness.ts new file mode 100644 index 00000000000000..ac46c0fc1526c8 --- /dev/null +++ b/packages/core-data/src/post-editor-awareness.ts @@ -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 ); + } + + /** + * 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 ); + } +} diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 0d68f5166e23a2..1197772713aa64 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -26,7 +26,6 @@ import { isNumericID, } from './utils'; import { fetchBlockPatterns } from './fetch'; -import { subscribeToUserSelectionChanges } from './utils/crdt-user-selections'; /** * Requests authors from the REST API. @@ -185,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, @@ -234,24 +235,8 @@ export const getEntityRecord = key ); }, - // Get the current user. - getCurrentUser: async () => { - await resolveSelect.getCurrentUser(); - }, - // Subscribe to user selection changes. - subscribeToUserSelectionChanges: ( - yDoc, - setSelectionState - ) => { - subscribeToUserSelectionChanges( - kind, - name, - key, - yDoc, - setSelectionState - ); - }, - } + }, + currentUser ); } } diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 9fef71adfba704..60e56079ee9a7c 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -172,8 +172,6 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), - getCurrentUser: expect.any( Function ), - subscribeToUserSelectionChanges: expect.any( Function ), } ); } ); @@ -227,8 +225,6 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), - getCurrentUser: expect.any( Function ), - subscribeToUserSelectionChanges: expect.any( Function ), } ); } ); diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index dac976505a8f2d..df42fcee0e6a28 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import type { Y, BaseState } from '@wordpress/sync'; + export interface AnyFunction { ( ...args: any[] ): any; } @@ -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; +} diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts index 91e5a9ed5cc884..45ca50dc1706d2 100644 --- a/packages/core-data/src/utils/crdt-user-selections.ts +++ b/packages/core-data/src/utils/crdt-user-selections.ts @@ -1,181 +1,23 @@ /** - * WordPress dependencies + * External dependencies */ -import { dispatch, select, subscribe } from '@wordpress/data'; -import { - Y, - LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, - CRDT_RECORD_MAP_KEY, -} from '@wordpress/sync'; -// @ts-ignore No exported types for block editor store selectors. -import { store as blockEditorStore } from '@wordpress/block-editor'; +import { Y, CRDT_RECORD_MAP_KEY } from '@wordpress/sync'; /** * Internal dependencies */ -import { STORE_NAME } from '../name'; -import { type WPBlockSelection } from '../types'; - -/** - * Convenience types to manage block values with a clientId, attributes, and innerBlocks. - */ -type BlockClientId = string; -type BlockInnerBlocks = Y.Array< SelectableBlock >; -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; - -/** - * Subscribe to user selection changes and update the selection state. - * - * @param kind - The kind of entity. - * @param name - The name of the entity. - * @param recordId - The ID of the entity. - * @param yDoc - Y.Doc - * @param setSelectionState - The function to set the selection state. - */ -export function subscribeToUserSelectionChanges( - kind: string, - name: string, - recordId: string | number, - yDoc: Y.Doc, - setSelectionState: ( selectionState: SelectionState ) => void -): 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 updateSelectionInEntityRecord( - kind, - name, - recordId, - 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, - yDoc - ); - setSelectionState( selectionState ); - }, LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS ); - } ); -} +import type { + WPBlockSelection, + SelectionState, + SelectableBlock, + CursorPosition, + SelectionNone, + SelectionCursor, + SelectionInOneBlock, + SelectionInMultipleBlocks, + SelectionWholeBlock, +} from '../types'; +import { SelectionType, type BlockInnerBlocks } from '../types'; /** * Converts WordPress block editor selection to a SelectionState. @@ -185,7 +27,7 @@ export function subscribeToUserSelectionChanges( * @param yDoc - The Yjs document * @return The SelectionState */ -function getSelectionState( +export function getSelectionState( selectionStart: WPBlockSelection, selectionEnd: WPBlockSelection, yDoc: Y.Doc @@ -271,54 +113,6 @@ function getSelectionState( }; } -/** - * Update the entity record with the current user's selection. - * - * @param kind - The kind of entity. - * @param name - The name of the entity. - * @param recordId - The ID of the entity. - * @param selectionStart - The start position of the selection. - * @param selectionEnd - The end position of the selection. - * @param initialPosition - The initial position of the selection. - */ -export async function updateSelectionInEntityRecord( - kind: string, - name: string, - recordId: string | number, - selectionStart: WPBlockSelection, - selectionEnd: WPBlockSelection, - initialPosition: number | null -): Promise< void > { - if ( ! selectionStart.clientId ) { - return; - } - - // 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 - Using STORE_NAME to avoid a circular dependency in the tests. - dispatch( STORE_NAME ).editEntityRecord( - kind, - name, - recordId, - edits, - options - ); -} - /** * Get the cursor position from a selection. * @@ -381,3 +175,97 @@ function findBlockByClientId( return null; } + +/** + * Check if two selection states are equal. + * + * @param selection1 - The first selection state. + * @param selection2 - The second selection state. + * @return True if the selection states are equal, false otherwise. + */ +export function areSelectionsStatesEqual( + selection1: SelectionState, + selection2: SelectionState +): boolean { + if ( selection1.type !== selection2.type ) { + return false; + } + + switch ( selection1.type ) { + case SelectionType.None: + return true; + + case SelectionType.Cursor: + return ( + selection1.blockId === + ( selection2 as SelectionCursor ).blockId && + areCursorPositionsEqual( + selection1.cursorPosition, + ( selection2 as SelectionCursor ).cursorPosition + ) + ); + + case SelectionType.SelectionInOneBlock: + return ( + selection1.blockId === + ( selection2 as SelectionInOneBlock ).blockId && + areCursorPositionsEqual( + selection1.cursorStartPosition, + ( selection2 as SelectionInOneBlock ).cursorStartPosition + ) && + areCursorPositionsEqual( + selection1.cursorEndPosition, + ( selection2 as SelectionInOneBlock ).cursorEndPosition + ) + ); + + case SelectionType.SelectionInMultipleBlocks: + return ( + selection1.blockStartId === + ( selection2 as SelectionInMultipleBlocks ).blockStartId && + selection1.blockEndId === + ( selection2 as SelectionInMultipleBlocks ).blockEndId && + areCursorPositionsEqual( + selection1.cursorStartPosition, + ( selection2 as SelectionInMultipleBlocks ) + .cursorStartPosition + ) && + areCursorPositionsEqual( + selection1.cursorEndPosition, + ( selection2 as SelectionInMultipleBlocks ) + .cursorEndPosition + ) + ); + case SelectionType.WholeBlock: + return ( + selection1.blockId === + ( selection2 as SelectionWholeBlock ).blockId + ); + + default: + return false; + } +} + +/** + * Check if two cursor positions are equal. + * + * @param cursorPosition1 - The first cursor position. + * @param cursorPosition2 - The second cursor position. + * @return True if the cursor positions are equal, false otherwise. + */ +function areCursorPositionsEqual( + cursorPosition1: CursorPosition, + cursorPosition2: CursorPosition +): boolean { + const isRelativePositionEqual = + JSON.stringify( cursorPosition1.relativePosition ) === + JSON.stringify( cursorPosition2.relativePosition ); + + // Ensure a change in calculated absolute offset results in a treating the cursor as modified. + // This is necessary because Y.Text relative positions can remain the same after text changes. + const isAbsoluteOffsetEqual = + cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset; + + return isRelativePositionEqual && isAbsoluteOffsetEqual; +} diff --git a/packages/sync/README.md b/packages/sync/README.md index 4041ab010d5772..5b29008f7a8bf3 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -14,6 +14,31 @@ npm install @wordpress/sync --save +### areUserInfosEqual + +Check if two user infos are equal. + +_Parameters_ + +- _userInfo1_ `UserInfo`: - The first user info. +- _userInfo2_ `UserInfo`: - The second user info. + +_Returns_ + +- `boolean`: True if the user infos are equal, false otherwise. + +### AwarenessState + +Abstract class to manage awareness and allow external code to subscribe to state updates. + +_Type_ + +- `AwarenessState` + +### AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS + +Delay in milliseconds before throttling the cursor position updates. + ### CRDT_DOC_META_PERSISTENCE_KEY CRDT documents can hold meta information in a map. This map exists only in memory and is not synced or persisted. This key can be used to indicate that a (temporary) document has been loaded from persistence. @@ -42,6 +67,19 @@ The sync manager orchestrates the lifecycle of syncing entity records. It create Deltas are used to calculate incremental Y.Text updates. +### generateUserInfo + +Generate a user info object from a current user and a list of existing colors. + +_Parameters_ + +- _currentUser_ `WordPressUserInfo`: - The current user. +- _existingColors_ `string[]`: - The existing colors. + +_Returns_ + +- `UserInfo`: The user info object. + ### LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS Delay in milliseconds before updating the cursor position. @@ -54,6 +92,14 @@ Origin string for CRDT document changes originating from the local editor. Origin string for CRDT document changes originating from the sync manager. +### TypedAwareness + +Extended Awareness class with typed state accessors. + +_Type_ + +- `TypedAwareness` + ### WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE WordPress meta key used to persist the CRDT document for an entity. diff --git a/packages/sync/src/awareness/awareness-manager.ts b/packages/sync/src/awareness/awareness-manager.ts deleted file mode 100644 index 625b61b6d64195..00000000000000 --- a/packages/sync/src/awareness/awareness-manager.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * External dependencies - */ -import type * as Y from 'yjs'; - -/** - * Internal dependencies - */ -import type { ObjectID, ObjectType, RecordHandlers } from '../types'; -import type { AwarenessState } from './awareness-state'; -import { PostEditorAwarenessState } from './post-editor-awareness-state'; -import type { UserInfo, WordPressUserInfo } from './awareness-types'; -import { getBrowserName, getNewUserColor } from '../user-utils'; - -function getUserInfo( - awareness: AwarenessState, - wpUser: WordPressUserInfo -): UserInfo { - const states = awareness.getStates(); - // TODO: There is a timing issue here. The other users aren't yet synced, and as a result the same color could be assigned to multiple users. - const otherUserColors = Array.from( states.entries() ) - .filter( - ( [ clientId, state ] ) => - state.userInfo && clientId !== awareness.clientID - ) - .map( ( [ , state ] ) => state.userInfo.color ) - .filter( Boolean ); - - return { - ...wpUser, - browserType: getBrowserName(), - color: getNewUserColor( otherUserColors ), - enteredAt: Date.now(), - }; -} - -/** - * Create an awareness instance for the given object type and object ID. - * @param objectType Object type. - * @param objectId Object ID. - * @param ydoc Yjs document. - * @param recordHandlers Record handlers. - * @return Awareness instance. - */ -export async function createAwareness( - objectType: ObjectType, - objectId: ObjectID | null, - ydoc: Y.Doc, - recordHandlers: RecordHandlers -): Promise< AwarenessState | undefined > { - if ( objectId && objectType.startsWith( 'postType/' ) ) { - const awareness = new PostEditorAwarenessState( ydoc ); - - const currentUser = await recordHandlers.getCurrentUser(); - const userInfo = getUserInfo( awareness, currentUser ); - - awareness.setUp( recordHandlers, userInfo ); - - return awareness; - } - return undefined; -} diff --git a/packages/sync/src/awareness/awareness-state.ts b/packages/sync/src/awareness/awareness-state.ts index 923a2aabf3947d..3506513e924b30 100644 --- a/packages/sync/src/awareness/awareness-state.ts +++ b/packages/sync/src/awareness/awareness-state.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import type { UserInfo } from './awareness-types'; +import type { RecordHandlers } from '../types'; import { TypedAwareness, type BaseState, @@ -10,7 +11,6 @@ import { } from './awareness-types'; import { getTypedKeys, areMapsEqual } from '../utils'; import { REMOVAL_DELAY_IN_MS } from '../config'; -import type { RecordHandlers } from '../types'; type AwarenessClientID = number; @@ -152,10 +152,10 @@ export abstract class AwarenessState< /** * Set up the awareness state. - * @param _recordHandlers - Record handlers. - * @param userInfo - User info. + * @param recordHandlers - Record handlers. + * @param userInfo - User info. */ - public setUp( _recordHandlers: RecordHandlers, userInfo: UserInfo ): void { + public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { this.setLocalStateField( 'userInfo', userInfo ); this.on( diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index 3ed2ea929d9500..fa92cf2a80453d 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -1,7 +1,12 @@ +/** + * External dependencies + */ import { Awareness } from 'y-protocols/awareness'; +/** + * Internal dependencies + */ import { getRecordValue } from '../utils'; -import type { SelectionState } from '../selection-utils'; /** * Extended Awareness class with typed state accessors. @@ -86,18 +91,3 @@ export type EqualityFieldCheck< State extends BaseState, FieldName extends keyof State, > = ( value1?: State[ FieldName ], value2?: State[ FieldName ] ) => boolean; - -/** - * 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; -} diff --git a/packages/sync/src/awareness/post-editor-awareness-state.ts b/packages/sync/src/awareness/post-editor-awareness-state.ts deleted file mode 100644 index 8e6e551d0ebbd1..00000000000000 --- a/packages/sync/src/awareness/post-editor-awareness-state.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Internal dependencies - */ -import type { PostEditorState, UserInfo } from './awareness-types'; -import type { RecordHandlers } from '../types'; -import { AwarenessState } from './awareness-state'; -import { areUserInfosEqual } from '../user-utils'; -import { AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS } from '../config'; -import { areEditorStatesEqual } from '../selection-utils'; - -export class PostEditorAwarenessState extends AwarenessState< PostEditorState > { - protected equalityFieldChecks = { - editorState: areEditorStatesEqual, - userInfo: areUserInfosEqual, - }; - - public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { - super.setUp( recordHandlers, userInfo ); - - // Subscribe to user selection changes. - recordHandlers.subscribeToUserSelectionChanges( - this.doc, - ( selectionState ) => - this.setThrottledLocalStateField( - 'editorState', - { selection: selectionState }, - AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS - ) - ); - } -} diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 6180aaa8e754e3..7bc9eea7b5e929 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -26,6 +26,10 @@ export { LOCAL_SYNC_MANAGER_ORIGIN, WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE, LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, + AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS, } from './config'; export { createSyncManager } from './manager'; export type * from './types'; +export type * from './awareness/awareness-types'; +export { AwarenessState } from './awareness/awareness-state'; +export { areUserInfosEqual, generateUserInfo } from './user-utils'; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index c25f28b746245b..ef999b44d17fe8 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -25,18 +25,18 @@ import type { SyncConfig, SyncManager, SyncUndoManager, - AwarenessID, } from './types'; import { createUndoManager } from './undo-manager'; import { createYjsDoc } from './utils'; -import { createAwareness } from './awareness/awareness-manager'; import type { AwarenessState } from './awareness/awareness-state'; +import type { WordPressUserInfo } from './awareness/awareness-types'; interface EntityState { handlers: RecordHandlers; objectId: ObjectID; objectType: ObjectType; syncConfig: SyncConfig; + awareness?: AwarenessState; unload: () => void; ydoc: CRDTDoc; } @@ -48,7 +48,6 @@ interface EntityState { */ export function createSyncManager(): SyncManager { const entityStates: Map< EntityID, EntityState > = new Map(); - const awarenessInstances: Map< AwarenessID, AwarenessState > = new Map(); /** * A "sync-aware" undo manager for all synced entities. It is lazily created @@ -84,18 +83,20 @@ export function createSyncManager(): SyncManager { /** * Load an entity for syncing and manage its lifecycle. * - * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {ObjectData} record Entity record representing this object type. - * @param {RecordHandlers} handlers Handlers for updating and fetching the record. + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {ObjectData} record Entity record representing this object type. + * @param {RecordHandlers} handlers Handlers for updating and fetching the record. + * @param {WordPressUserInfo} currentUser Current user. */ async function loadEntity( syncConfig: SyncConfig, objectType: ObjectType, objectId: ObjectID, record: ObjectData, - handlers: RecordHandlers + handlers: RecordHandlers, + currentUser: WordPressUserInfo ): Promise< void > { const providerCreators = getProviderCreators(); @@ -109,8 +110,6 @@ export function createSyncManager(): SyncManager { return; // Already bootstrapped. } - const awarenessId = getAwarenessId( objectType, objectId ); - const ydoc = createYjsDoc( { objectType } ); const recordMap = ydoc.getMap( RECORD_KEY ); const recordMetaMap = ydoc.getMap( RECORD_METADATA_KEY ); @@ -122,9 +121,15 @@ export function createSyncManager(): SyncManager { recordMap.unobserveDeep( onRecordUpdate ); ydoc.destroy(); entityStates.delete( entityId ); - awarenessInstances.delete( awarenessId ); }; + // If the sync config supports awareness, create it. + const awareness = syncConfig.createAwareness?.( + ydoc, + handlers, + currentUser + ); + // When the CRDT document is updated by an UndoManager or a connection (not // a local origin), update the local store. const onRecordUpdate = ( @@ -174,25 +179,13 @@ export function createSyncManager(): SyncManager { objectId, objectType, syncConfig, + awareness, unload, ydoc, }; entityStates.set( entityId, entityState ); - // Create awareness for the given entity and its Yjs document. - const awareness = await createAwareness( - objectType, - objectId, - ydoc, - handlers - ); - - // Awareness can be undefined, if the object type is not supported. - if ( awareness ) { - awarenessInstances.set( awarenessId, awareness ); - } - // Create providers for the given entity and its Yjs document. const providerResults = await Promise.all( providerCreators.map( ( create ) => @@ -232,16 +225,24 @@ export function createSyncManager(): SyncManager { } /** - * Get the awareness ID for the given object type and object ID. + * Get the awareness instance for the given object type and object ID, if supported. * * @param {ObjectType} objectType Object type. * @param {ObjectID} objectId Object ID. + * @return {AwarenessState | undefined} The awareness instance, or undefined if not supported. */ - function getAwarenessId( + function getAwarenessInstance( objectType: ObjectType, objectId: ObjectID - ): AwarenessID { - return `${ objectType }:${ objectId }`; + ): AwarenessState | undefined { + const entityId = getEntityId( objectType, objectId ); + const entityState = entityStates.get( entityId ); + + if ( ! entityState || ! entityState.awareness ) { + return undefined; + } + + return entityState.awareness; } /** @@ -445,6 +446,7 @@ export function createSyncManager(): SyncManager { return { createMeta: createEntityMeta, + getAwarenessInstance, load: loadEntity, // Use getter to ensure we always return the current value of `undoManager`. get undoManager(): SyncUndoManager | undefined { diff --git a/packages/sync/src/selection-utils.ts b/packages/sync/src/selection-utils.ts deleted file mode 100644 index 9ff8839cc9d012..00000000000000 --- a/packages/sync/src/selection-utils.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * External dependencies - */ -import type * as Y from 'yjs'; - -/** - * Internal dependencies - */ -import type { EditorState } from './awareness/awareness-types'; - -/** - * 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; - -/** - * Check if two selection states are equal. - * - * @param selection1 - The first selection state. - * @param selection2 - The second selection state. - * @return True if the selection states are equal, false otherwise. - */ -function areSelectionsEqual( - selection1: SelectionState, - selection2: SelectionState -): boolean { - if ( selection1.type !== selection2.type ) { - return false; - } - - switch ( selection1.type ) { - case SelectionType.None: - return true; - - case SelectionType.Cursor: - return ( - selection1.blockId === - ( selection2 as SelectionCursor ).blockId && - areCursorPositionsEqual( - selection1.cursorPosition, - ( selection2 as SelectionCursor ).cursorPosition - ) - ); - - case SelectionType.SelectionInOneBlock: - return ( - selection1.blockId === - ( selection2 as SelectionInOneBlock ).blockId && - areCursorPositionsEqual( - selection1.cursorStartPosition, - ( selection2 as SelectionInOneBlock ).cursorStartPosition - ) && - areCursorPositionsEqual( - selection1.cursorEndPosition, - ( selection2 as SelectionInOneBlock ).cursorEndPosition - ) - ); - - case SelectionType.SelectionInMultipleBlocks: - return ( - selection1.blockStartId === - ( selection2 as SelectionInMultipleBlocks ).blockStartId && - selection1.blockEndId === - ( selection2 as SelectionInMultipleBlocks ).blockEndId && - areCursorPositionsEqual( - selection1.cursorStartPosition, - ( selection2 as SelectionInMultipleBlocks ) - .cursorStartPosition - ) && - areCursorPositionsEqual( - selection1.cursorEndPosition, - ( selection2 as SelectionInMultipleBlocks ) - .cursorEndPosition - ) - ); - case SelectionType.WholeBlock: - return ( - selection1.blockId === - ( selection2 as SelectionWholeBlock ).blockId - ); - - default: - return false; - } -} - -/** - * 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. - */ -export function areEditorStatesEqual( - state1?: EditorState, - state2?: EditorState -): boolean { - if ( ! state1 || ! state2 ) { - return state1 === state2; - } - - return areSelectionsEqual( state1.selection, state2.selection ); -} - -/** - * Check if two cursor positions are equal. - * - * @param cursorPosition1 - The first cursor position. - * @param cursorPosition2 - The second cursor position. - * @return True if the cursor positions are equal, false otherwise. - */ -function areCursorPositionsEqual( - cursorPosition1: CursorPosition, - cursorPosition2: CursorPosition -): boolean { - const isRelativePositionEqual = - JSON.stringify( cursorPosition1.relativePosition ) === - JSON.stringify( cursorPosition2.relativePosition ); - - // Ensure a change in calculated absolute offset results in a treating the cursor as modified. - // This is necessary because Y.Text relative positions can remain the same after text changes. - const isAbsoluteOffsetEqual = - cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset; - - return isRelativePositionEqual && isAbsoluteOffsetEqual; -} diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index a16b64ecc06234..c214654697129c 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -96,6 +96,8 @@ describe( 'SyncManager', () => { } ), supports: {}, + // TODO: Switch this to the generic awareness state implementation instead. + createAwareness: jest.fn( () => undefined ), }; mockHandlers = { @@ -105,10 +107,6 @@ describe( 'SyncManager', () => { ), refetchRecord: jest.fn( async () => Promise.resolve() ), saveRecord: jest.fn( async () => Promise.resolve() ), - getCurrentUser: jest.fn( async () => - Promise.resolve( mockCurrentUser ) - ), - subscribeToUserSelectionChanges: jest.fn(), }; } ); @@ -132,7 +130,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); // Verify that applyChangesToCRDTDoc was called with the record data @@ -150,7 +149,8 @@ describe( 'SyncManager', () => { 'postType/post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); @@ -172,7 +172,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); expect( @@ -189,7 +190,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); await manager.load( @@ -197,7 +199,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); // Should only be called once despite two load attempts @@ -218,7 +221,8 @@ describe( 'SyncManager', () => { 'post', '123', record1, - mockHandlers + mockHandlers, + mockCurrentUser ); await manager.load( @@ -226,7 +230,8 @@ describe( 'SyncManager', () => { 'post', '456', record2, - mockHandlers + mockHandlers, + mockCurrentUser ); expect( @@ -274,7 +279,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); // Current record should be applied as changes since the persisted doc does not exist. @@ -309,7 +315,8 @@ describe( 'SyncManager', () => { 'post', '123', record, - mockHandlers + mockHandlers, + mockCurrentUser ); // Changes should NOT be applied since the persisted doc is valid. @@ -348,7 +355,8 @@ describe( 'SyncManager', () => { 'post', '123', record, - mockHandlers + mockHandlers, + mockCurrentUser ); // Changes should be applied for the invalidated properties. @@ -388,7 +396,8 @@ describe( 'SyncManager', () => { 'post', '123', record, - mockHandlers + mockHandlers, + mockCurrentUser ); // Current record should be applied since the persisted doc does not exist. @@ -419,7 +428,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); manager.unload( 'post', '123' ); @@ -443,7 +453,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); manager.unload( 'post', '123' ); @@ -455,7 +466,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); expect( @@ -472,7 +484,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); await manager.load( @@ -480,7 +493,8 @@ describe( 'SyncManager', () => { 'post', '456', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); manager.unload( 'post', '123' ); @@ -518,7 +532,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); jest.clearAllMocks(); @@ -571,7 +586,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); // Get the captured Y.Doc @@ -615,7 +631,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); jest.clearAllMocks(); @@ -663,7 +680,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); // Clear calls of editRecord, which is called during load. @@ -712,7 +730,8 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers + mockHandlers, + mockCurrentUser ); // Clear calls of editRecord, which is called during load. diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 6c3a15593cee60..aaa8be713f2c77 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -14,7 +14,7 @@ import type { Awareness } from 'y-protocols/awareness'; */ import type { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from './config'; import type { WordPressUserInfo } from './awareness/awareness-types'; -import type { SelectionState } from './selection-utils'; +import type { AwarenessState } from './awareness/awareness-state'; /* globalThis */ declare global { @@ -71,11 +71,6 @@ export interface RecordHandlers { getEditedRecord: () => Promise< ObjectData >; refetchRecord: () => Promise< void >; saveRecord: () => Promise< void >; - getCurrentUser: () => Promise< WordPressUserInfo >; - subscribeToUserSelectionChanges: ( - yDoc: Y.Doc, - setSelectionState: ( selectionState: SelectionState ) => void - ) => void; } export interface SyncConfig { @@ -88,6 +83,11 @@ export interface SyncConfig { editedRecord: ObjectData ) => ObjectData; supports?: Record< string, true >; + createAwareness?: ( + ydoc: Y.Doc, + recordHandlers: RecordHandlers, + currentUser: WordPressUserInfo + ) => AwarenessState | undefined; } export interface SyncManager { @@ -95,12 +95,17 @@ export interface SyncManager { objectType: ObjectType, objectId: ObjectID ) => Record< string, string >; + getAwarenessInstance: ( + objectType: ObjectType, + objectId: ObjectID + ) => AwarenessState | undefined; load: ( syncConfig: SyncConfig, objectType: ObjectType, objectId: ObjectID, record: ObjectData, - handlers: RecordHandlers + handlers: RecordHandlers, + currentUser: WordPressUserInfo ) => Promise< void >; // undoManager is undefined until the first entity is loaded. undoManager: SyncUndoManager | undefined; diff --git a/packages/sync/src/user-utils.ts b/packages/sync/src/user-utils.ts index b84bb24c3c0b48..438940b843c124 100644 --- a/packages/sync/src/user-utils.ts +++ b/packages/sync/src/user-utils.ts @@ -1,4 +1,4 @@ -import type { UserInfo } from './awareness/awareness-types'; +import type { UserInfo, WordPressUserInfo } from './awareness/awareness-types'; import { loadFromLocalStorage, saveToLocalStorage } from './local-storage'; /** @@ -35,7 +35,7 @@ function generateRandomInt( min: number, max: number ): number { * @param existingColors - Colors that are already in use. * @return The new user color, in hex format. */ -export function getNewUserColor( existingColors: string[] ): string { +function getNewUserColor( existingColors: string[] ): string { const availableColors = COLOR_PALETTE.filter( ( color ) => ! existingColors.includes( color ) ); @@ -93,7 +93,7 @@ function generateColorVariation( hexColor: string ): string { * Get the browser name from the user agent. * @return The browser name. */ -export function getBrowserName(): string { +function getBrowserName(): string { const userAgent = window.navigator.userAgent; let browserName = 'Unknown'; @@ -123,6 +123,13 @@ export function getBrowserName(): string { return browserName; } +/** + * Check if two user infos are equal. + * + * @param userInfo1 - The first user info. + * @param userInfo2 - The second user info. + * @return True if the user infos are equal, false otherwise. + */ export function areUserInfosEqual( userInfo1?: UserInfo, userInfo2?: UserInfo @@ -140,3 +147,22 @@ export function areUserInfosEqual( return value === userInfo2[ key as keyof UserInfo ]; } ); } + +/** + * Generate a user info object from a current user and a list of existing colors. + * + * @param currentUser - The current user. + * @param existingColors - The existing colors. + * @return The user info object. + */ +export function generateUserInfo( + currentUser: WordPressUserInfo, + existingColors: string[] +): UserInfo { + return { + ...currentUser, + browserType: getBrowserName(), + color: getNewUserColor( existingColors ), + enteredAt: Date.now(), + }; +} From 2d6ca82a0ad48133809461fdc64c1f2a3d76e43d Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 22 Jan 2026 15:50:23 +1100 Subject: [PATCH 24/30] Replace undefined awareness test with a mock --- packages/sync/src/test/manager.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index c214654697129c..346de8c0c2afb6 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -33,7 +33,20 @@ import type { RecordHandlers, SyncConfig, } from '../types'; -import type { WordPressUserInfo } from '../awareness/awareness-types'; +import type { + WordPressUserInfo, + BaseState, +} from '../awareness/awareness-types'; +import { AwarenessState } from '../awareness/awareness-state'; + +/** + * A minimal mock awareness class for testing. + */ +class MockAwarenessState extends AwarenessState< BaseState > { + protected equalityFieldChecks = { + userInfo: () => true, + }; +} // Mock dependencies. jest.mock( '../providers', () => ( { @@ -96,8 +109,9 @@ describe( 'SyncManager', () => { } ), supports: {}, - // TODO: Switch this to the generic awareness state implementation instead. - createAwareness: jest.fn( () => undefined ), + createAwareness: jest.fn( + ( ydoc: Y.Doc ) => new MockAwarenessState( ydoc ) + ), }; mockHandlers = { From 335f32feaeb3935dc90f801bda506549545652b6 Mon Sep 17 00:00:00 2001 From: ingeniumed Date: Thu, 22 Jan 2026 16:07:41 +1100 Subject: [PATCH 25/30] Fix the tests failures in resolvers --- packages/core-data/src/test/resolvers.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index 60e56079ee9a7c..fede43b9188bef 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -146,6 +146,9 @@ describe( 'getEntityRecord', () => { const resolveSelectWithSync = { getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), getEditedEntityRecord: jest.fn(), + getCurrentUser: jest.fn( () => + Promise.resolve( { id: 1, name: 'Test User' } ) + ), }; triggerFetch.mockImplementation( () => POST_RESPONSE ); @@ -172,7 +175,8 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), - } + }, + { id: 1, name: 'Test User' } ); } ); @@ -199,6 +203,9 @@ describe( 'getEntityRecord', () => { const resolveSelectWithSync = { getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), getEditedEntityRecord: jest.fn(), + getCurrentUser: jest.fn( () => + Promise.resolve( { id: 1, name: 'Test User' } ) + ), }; triggerFetch.mockImplementation( () => POST_RESPONSE ); @@ -225,7 +232,8 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), - } + }, + { id: 1, name: 'Test User' } ); } ); From 731eea53ed14536d921b5c118627f9da9143ef91 Mon Sep 17 00:00:00 2001 From: Chris Zarate Date: Thu, 22 Jan 2026 13:09:33 -0700 Subject: [PATCH 26/30] Improve types and keep WordPress domain knowledge out of sync package (#74869) * Improve types and keep WordPress domain knowledge out of sync package * Fix core-data tests --- packages/core-data/src/awareness/config.ts | 9 +++ .../{ => awareness}/post-editor-awareness.ts | 76 +++++++++++------ packages/core-data/src/awareness/types.ts | 38 +++++++++ .../src/awareness/utils.ts} | 23 ++---- packages/core-data/src/entities.js | 39 ++++----- packages/core-data/src/resolvers.js | 5 +- packages/core-data/src/test/resolvers.js | 12 +-- packages/core-data/src/types.ts | 17 +--- packages/sync/README.md | 34 -------- .../sync/src/awareness/awareness-state.ts | 23 +++--- .../sync/src/awareness/awareness-types.ts | 46 ++--------- packages/sync/src/config.ts | 10 --- packages/sync/src/index.ts | 8 +- packages/sync/src/local-storage.ts | 53 ------------ packages/sync/src/manager.ts | 26 +++--- packages/sync/src/test/manager.ts | 81 +++++-------------- packages/sync/src/types.ts | 15 ++-- 17 files changed, 181 insertions(+), 334 deletions(-) create mode 100644 packages/core-data/src/awareness/config.ts rename packages/core-data/src/{ => awareness}/post-editor-awareness.ts (73%) create mode 100644 packages/core-data/src/awareness/types.ts rename packages/{sync/src/user-utils.ts => core-data/src/awareness/utils.ts} (86%) delete mode 100644 packages/sync/src/local-storage.ts diff --git a/packages/core-data/src/awareness/config.ts b/packages/core-data/src/awareness/config.ts new file mode 100644 index 00000000000000..9874e059650dca --- /dev/null +++ b/packages/core-data/src/awareness/config.ts @@ -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; diff --git a/packages/core-data/src/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts similarity index 73% rename from packages/core-data/src/post-editor-awareness.ts rename to packages/core-data/src/awareness/post-editor-awareness.ts index ac46c0fc1526c8..39686880002969 100644 --- a/packages/core-data/src/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/post-editor-awareness.ts @@ -1,26 +1,27 @@ /** * 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'; +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, PostEditorState, EditorState } from './types'; +} from '../utils/crdt-user-selections'; + +import type { WPBlockSelection } from '../types'; +import type { EditorState, PostEditorState } from './types'; export class PostEditorAwareness extends AwarenessState< PostEditorState > { protected equalityFieldChecks = { @@ -28,20 +29,45 @@ export class PostEditorAwareness extends AwarenessState< PostEditorState > { userInfo: areUserInfosEqual, }; - public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { - super.setUp( recordHandlers, userInfo ); + public constructor( + doc: Y.Doc, + private kind: string, + private name: string, + private postId: number + ) { + super( doc ); + } + + public setUp(): void { + super.setUp(); - this.subscribeToUserSelectionChanges( recordHandlers ); + 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. - * - * @param recordHandlers - The record handlers. */ - private subscribeToUserSelectionChanges( - recordHandlers: RecordHandlers - ): void { + private subscribeToUserSelectionChanges(): void { const { getSelectionStart, getSelectionEnd, @@ -73,7 +99,6 @@ export class PostEditorAwareness extends AwarenessState< PostEditorState > { // Ensure we update the controlled selection right away, persisting our cursor position locally. const initialPosition = getSelectedBlocksInitialCaretPosition(); void this.updateSelectionInEntityRecord( - recordHandlers, selectionStart, selectionEnd, initialPosition @@ -107,13 +132,11 @@ export class PostEditorAwareness extends AwarenessState< PostEditorState > { /** * 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 @@ -134,7 +157,14 @@ export class PostEditorAwareness extends AwarenessState< PostEditorState > { undoIgnore: true, }; - recordHandlers.editRecord( edits, options ); + // @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 + ); } /** diff --git a/packages/core-data/src/awareness/types.ts b/packages/core-data/src/awareness/types.ts new file mode 100644 index 00000000000000..79ff9621366037 --- /dev/null +++ b/packages/core-data/src/awareness/types.ts @@ -0,0 +1,38 @@ +/** + * Internal dependencies + */ +import type { User } from '../entity-types'; +import type { SelectionState } from '../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; +} diff --git a/packages/sync/src/user-utils.ts b/packages/core-data/src/awareness/utils.ts similarity index 86% rename from packages/sync/src/user-utils.ts rename to packages/core-data/src/awareness/utils.ts index 438940b843c124..74c66293eee1d9 100644 --- a/packages/sync/src/user-utils.ts +++ b/packages/core-data/src/awareness/utils.ts @@ -1,5 +1,8 @@ -import type { UserInfo, WordPressUserInfo } from './awareness/awareness-types'; -import { loadFromLocalStorage, saveToLocalStorage } from './local-storage'; +/** + * Internal dependencies + */ +import type { User } from '../entity-types'; +import type { UserInfo } from './types'; /** * The color palette for the user highlight. @@ -15,8 +18,6 @@ const COLOR_PALETTE = [ '#37C5F0', // cyan ]; -const LOCAL_STORAGE_KEY = 'GUTENBERG_PREFERRED_COLOR_KEY'; - /** * Generate a random integer between min and max, inclusive. * @@ -40,17 +41,9 @@ function getNewUserColor( existingColors: string[] ): string { ( color ) => ! existingColors.includes( color ) ); - // TODO: Drop this, and use @wordpress/preferences instead. - const preferredColor = loadFromLocalStorage< string | null >( - LOCAL_STORAGE_KEY, - null - ); - let hexColor: string; - if ( preferredColor && availableColors.includes( preferredColor ) ) { - hexColor = preferredColor; - } else if ( availableColors.length > 0 ) { + if ( availableColors.length > 0 ) { const randomIndex = generateRandomInt( 0, availableColors.length - 1 ); hexColor = availableColors[ randomIndex ]; } else { @@ -60,8 +53,6 @@ function getNewUserColor( existingColors: string[] ): string { hexColor = generateColorVariation( baseColor ); } - // TODO: Drop this, and use @wordpress/preferences instead. - saveToLocalStorage( LOCAL_STORAGE_KEY, hexColor ); return hexColor; } @@ -156,7 +147,7 @@ export function areUserInfosEqual( * @return The user info object. */ export function generateUserInfo( - currentUser: WordPressUserInfo, + currentUser: User< 'view' >, existingColors: string[] ): UserInfo { return { diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 8bf1894b90801b..92467602b00e47 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -9,17 +9,16 @@ 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 */ +import { PostEditorAwareness } from './awareness/post-editor-awareness'; import { getSyncManager } from './sync'; 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' ]; @@ -360,6 +359,19 @@ async function loadPostTypeEntities() { applyChangesToCRDTDoc: ( crdtDoc, changes ) => applyPostChangesToCRDTDoc( crdtDoc, changes, postType ), + /** + * Create the awareness instance for the entity's CRDT document. + * + * @param {import('@wordpress/sync').CRDTDoc} ydoc + * @param {import('@wordpress/sync').ObjectID} objectId + * @return {import('@wordpress/sync').AwarenessState} AwarenessState instance + */ + createAwareness: ( ydoc, objectId ) => { + const kind = 'postType'; + const id = parseInt( objectId, 10 ); + return new PostEditorAwareness( ydoc, kind, name, id ); + }, + /** * Extract changes from a CRDT document that can be used to update the * local editor state. @@ -383,29 +395,6 @@ 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; - }, }; } diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 1197772713aa64..453733e6a606cd 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -184,8 +184,6 @@ export const getEntityRecord = transientConfig.read( recordWithTransients ); } ); - const currentUser = await resolveSelect.getCurrentUser(); - // Load the entity record for syncing. await getSyncManager()?.load( entityConfig.syncConfig, @@ -235,8 +233,7 @@ export const getEntityRecord = key ); }, - }, - currentUser + } ); } } diff --git a/packages/core-data/src/test/resolvers.js b/packages/core-data/src/test/resolvers.js index fede43b9188bef..60e56079ee9a7c 100644 --- a/packages/core-data/src/test/resolvers.js +++ b/packages/core-data/src/test/resolvers.js @@ -146,9 +146,6 @@ describe( 'getEntityRecord', () => { const resolveSelectWithSync = { getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), getEditedEntityRecord: jest.fn(), - getCurrentUser: jest.fn( () => - Promise.resolve( { id: 1, name: 'Test User' } ) - ), }; triggerFetch.mockImplementation( () => POST_RESPONSE ); @@ -175,8 +172,7 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), - }, - { id: 1, name: 'Test User' } + } ); } ); @@ -203,9 +199,6 @@ describe( 'getEntityRecord', () => { const resolveSelectWithSync = { getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ), getEditedEntityRecord: jest.fn(), - getCurrentUser: jest.fn( () => - Promise.resolve( { id: 1, name: 'Test User' } ) - ), }; triggerFetch.mockImplementation( () => POST_RESPONSE ); @@ -232,8 +225,7 @@ describe( 'getEntityRecord', () => { getEditedRecord: expect.any( Function ), refetchRecord: expect.any( Function ), saveRecord: expect.any( Function ), - }, - { id: 1, name: 'Test User' } + } ); } ); diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index df42fcee0e6a28..be71e5434fa049 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { Y, BaseState } from '@wordpress/sync'; +import type { Y } from '@wordpress/sync'; export interface AnyFunction { ( ...args: any[] ): any; @@ -104,18 +104,3 @@ export type SelectionState = | 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; -} diff --git a/packages/sync/README.md b/packages/sync/README.md index 5b29008f7a8bf3..bda5bc28363bce 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -14,19 +14,6 @@ npm install @wordpress/sync --save -### areUserInfosEqual - -Check if two user infos are equal. - -_Parameters_ - -- _userInfo1_ `UserInfo`: - The first user info. -- _userInfo2_ `UserInfo`: - The second user info. - -_Returns_ - -- `boolean`: True if the user infos are equal, false otherwise. - ### AwarenessState Abstract class to manage awareness and allow external code to subscribe to state updates. @@ -35,10 +22,6 @@ _Type_ - `AwarenessState` -### AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS - -Delay in milliseconds before throttling the cursor position updates. - ### CRDT_DOC_META_PERSISTENCE_KEY CRDT documents can hold meta information in a map. This map exists only in memory and is not synced or persisted. This key can be used to indicate that a (temporary) document has been loaded from persistence. @@ -67,23 +50,6 @@ The sync manager orchestrates the lifecycle of syncing entity records. It create Deltas are used to calculate incremental Y.Text updates. -### generateUserInfo - -Generate a user info object from a current user and a list of existing colors. - -_Parameters_ - -- _currentUser_ `WordPressUserInfo`: - The current user. -- _existingColors_ `string[]`: - The existing colors. - -_Returns_ - -- `UserInfo`: The user info object. - -### LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS - -Delay in milliseconds before updating the cursor position. - ### LOCAL_EDITOR_ORIGIN Origin string for CRDT document changes originating from the local editor. diff --git a/packages/sync/src/awareness/awareness-state.ts b/packages/sync/src/awareness/awareness-state.ts index 3506513e924b30..b211e46abf9a37 100644 --- a/packages/sync/src/awareness/awareness-state.ts +++ b/packages/sync/src/awareness/awareness-state.ts @@ -1,11 +1,8 @@ /** * Internal dependencies */ -import type { UserInfo } from './awareness-types'; -import type { RecordHandlers } from '../types'; import { TypedAwareness, - type BaseState, type EnhancedState, type EqualityFieldCheck, } from './awareness-types'; @@ -21,7 +18,7 @@ interface AwarenessStateChange { } abstract class AwarenessWithEqualityChecks< - State extends BaseState = BaseState, + State extends object, > extends TypedAwareness< State > { /** OVERRIDDEN METHODS */ @@ -115,7 +112,7 @@ abstract class AwarenessWithEqualityChecks< * state updates. */ export abstract class AwarenessState< - State extends BaseState = BaseState, + State extends object = {}, > extends AwarenessWithEqualityChecks< State > { /** CUSTOM PROPERTIES */ @@ -152,12 +149,8 @@ export abstract class AwarenessState< /** * Set up the awareness state. - * @param recordHandlers - Record handlers. - * @param userInfo - User info. */ - public setUp( recordHandlers: RecordHandlers, userInfo: UserInfo ): void { - this.setLocalStateField( 'userInfo', userInfo ); - + public setUp(): void { this.on( 'change', ( { added, removed, updated }: AwarenessStateChange ) => { @@ -265,10 +258,12 @@ export abstract class AwarenessState< const updatedStates = new Map< number, EnhancedState< State > >( [ ...this.disconnectedUsers, ...states.keys() ] .filter( ( clientId ) => { - // Exclude any users without `userInfo`. - // This can happen from the Yjs inspector, which joins the awareness - // state without providing user data. - return Boolean( this.seenStates.get( clientId )?.userInfo ); + // Exclude any users with empty awareness state. This can happen from + // the Yjs inspector. + return ( + Object.keys( this.seenStates.get( clientId ) ?? {} ) + .length > 0 + ); } ) .map( ( clientId ) => { // The filter above ensures that seenStates has the clientId. diff --git a/packages/sync/src/awareness/awareness-types.ts b/packages/sync/src/awareness/awareness-types.ts index fa92cf2a80453d..c4f7cecda691c7 100644 --- a/packages/sync/src/awareness/awareness-types.ts +++ b/packages/sync/src/awareness/awareness-types.ts @@ -11,7 +11,7 @@ import { getRecordValue } from '../utils'; /** * Extended Awareness class with typed state accessors. */ -export class TypedAwareness< State extends BaseState > extends Awareness { +export class TypedAwareness< State extends object > extends Awareness { /** * Get the states from an awareness document. */ @@ -43,51 +43,17 @@ export class TypedAwareness< State extends BaseState > extends Awareness { } } -/** - * This base user info is a subset of the User interface from @wordpress/core-data. - * - * In order to avoid circular dependencies, we define it here instead of importing - * the User interface from @wordpress/core-data. - * - * The avatarUrl is an additional field that is not part of the User interface. - */ -export interface WordPressUserInfo { - id: number; - name: string; - slug: string; - avatar_urls: Record< string, string >; -} - -/** - * The user info interface extends the base user info with additional fields used for presence - * indicators. - */ -export interface UserInfo extends WordPressUserInfo { - 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; -} - /** * An enhanced state includes additional metadata about the user's connection * that is not appropriate to synchronize via Yjs awareness. */ -export type EnhancedState< State extends BaseState > = State & { +export type EnhancedState< State > = State & { clientId: number; isConnected: boolean; isMe: boolean; }; -export type EqualityFieldCheck< - State extends BaseState, - FieldName extends keyof State, -> = ( value1?: State[ FieldName ], value2?: State[ FieldName ] ) => boolean; +export type EqualityFieldCheck< State, FieldName extends keyof State > = ( + value1?: State[ FieldName ], + value2?: State[ FieldName ] +) => boolean; diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts index 17f65d080cebbf..11eec60c670699 100644 --- a/packages/sync/src/config.ts +++ b/packages/sync/src/config.ts @@ -63,13 +63,3 @@ export const WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document'; * Delay in milliseconds before removing a user from presence indicators. */ export const REMOVAL_DELAY_IN_MS = 5000; - -/** - * Delay in milliseconds before updating the cursor position. - */ -export const LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS = 5; - -/** - * Delay in milliseconds before throttling the cursor position updates. - */ -export const AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS = 100; diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 7bc9eea7b5e929..7e5eb8c04ed203 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -16,6 +16,7 @@ export * as Y from 'yjs'; */ export { default as Delta } from './quill-delta/Delta'; +export { AwarenessState } from './awareness/awareness-state'; export { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY, @@ -25,11 +26,8 @@ export { LOCAL_EDITOR_ORIGIN, LOCAL_SYNC_MANAGER_ORIGIN, WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE, - LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS, - AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS, } from './config'; export { createSyncManager } from './manager'; -export type * from './types'; + export type * from './awareness/awareness-types'; -export { AwarenessState } from './awareness/awareness-state'; -export { areUserInfosEqual, generateUserInfo } from './user-utils'; +export type * from './types'; diff --git a/packages/sync/src/local-storage.ts b/packages/sync/src/local-storage.ts deleted file mode 100644 index b1465288020a90..00000000000000 --- a/packages/sync/src/local-storage.ts +++ /dev/null @@ -1,53 +0,0 @@ -// TODO: Drop this file, and use @wordpress/preferences instead. - -/** - * Load data from localStorage with error handling - * @param key - The localStorage key to read from - * @param defaultValue - The default value to return if loading fails or key doesn't exist - * @return The parsed data from localStorage or the default value - */ -export const loadFromLocalStorage = < T >( - key: string, - defaultValue: T -): T => { - try { - const saved = window?.localStorage?.getItem( key ); - if ( saved ) { - const parsed = JSON.parse( saved ) as T; - // If the parsed value is an object (and not null or array), merge with defaultValue - if ( - typeof parsed === 'object' && - parsed !== null && - ! Array.isArray( parsed ) - ) { - return { ...defaultValue, ...( parsed as Partial< T > ) }; - } - // For primitive values (string, number, boolean, null), return directly - return parsed; - } - } catch ( error ) { - // eslint-disable-next-line no-console - console.warn( - `Failed to load data from localStorage (key: ${ key }):`, - error - ); - } - return defaultValue; -}; - -/** - * Save data to localStorage with error handling - * @param key - The localStorage key to write to - * @param data - The data to save - */ -export const saveToLocalStorage = < T >( key: string, data: T ): void => { - try { - localStorage.setItem( key, JSON.stringify( data ) ); - } catch ( error ) { - // eslint-disable-next-line no-console - console.warn( - `Failed to save data to localStorage (key: ${ key }):`, - error - ); - } -}; diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index ef999b44d17fe8..e040e499be839f 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -29,14 +29,13 @@ import type { import { createUndoManager } from './undo-manager'; import { createYjsDoc } from './utils'; import type { AwarenessState } from './awareness/awareness-state'; -import type { WordPressUserInfo } from './awareness/awareness-types'; interface EntityState { + awareness?: AwarenessState; handlers: RecordHandlers; objectId: ObjectID; objectType: ObjectType; syncConfig: SyncConfig; - awareness?: AwarenessState; unload: () => void; ydoc: CRDTDoc; } @@ -83,20 +82,18 @@ export function createSyncManager(): SyncManager { /** * Load an entity for syncing and manage its lifecycle. * - * @param {SyncConfig} syncConfig Sync configuration for the object type. - * @param {ObjectType} objectType Object type. - * @param {ObjectID} objectId Object ID. - * @param {ObjectData} record Entity record representing this object type. - * @param {RecordHandlers} handlers Handlers for updating and fetching the record. - * @param {WordPressUserInfo} currentUser Current user. + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + * @param {ObjectData} record Entity record representing this object type. + * @param {RecordHandlers} handlers Handlers for updating and fetching the record. */ async function loadEntity( syncConfig: SyncConfig, objectType: ObjectType, objectId: ObjectID, record: ObjectData, - handlers: RecordHandlers, - currentUser: WordPressUserInfo + handlers: RecordHandlers ): Promise< void > { const providerCreators = getProviderCreators(); @@ -124,11 +121,8 @@ export function createSyncManager(): SyncManager { }; // If the sync config supports awareness, create it. - const awareness = syncConfig.createAwareness?.( - ydoc, - handlers, - currentUser - ); + const awareness = syncConfig.createAwareness?.( ydoc, objectId ); + awareness?.setUp(); // When the CRDT document is updated by an UndoManager or a connection (not // a local origin), update the local store. @@ -175,11 +169,11 @@ export function createSyncManager(): SyncManager { undoManager.addToScope( recordMap ); const entityState: EntityState = { + awareness, handlers, objectId, objectType, syncConfig, - awareness, unload, ydoc, }; diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index 346de8c0c2afb6..0120fc0ed54ad7 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -33,16 +33,12 @@ import type { RecordHandlers, SyncConfig, } from '../types'; -import type { - WordPressUserInfo, - BaseState, -} from '../awareness/awareness-types'; import { AwarenessState } from '../awareness/awareness-state'; /** * A minimal mock awareness class for testing. */ -class MockAwarenessState extends AwarenessState< BaseState > { +class MockAwarenessState extends AwarenessState { protected equalityFieldChecks = { userInfo: () => true, }; @@ -59,7 +55,6 @@ describe( 'SyncManager', () => { let mockProviderCreator: jest.Mock< ProviderCreator >; let mockProviderResult: ProviderCreatorResult; let mockRecord: ObjectData; - let mockCurrentUser: WordPressUserInfo; let mockSyncConfig: jest.MockedObject< SyncConfig >; beforeEach( () => { @@ -71,17 +66,6 @@ describe( 'SyncManager', () => { title: 'Test Post', }; - mockCurrentUser = { - id: 1, - name: 'Test User', - slug: 'test-user', - avatar_urls: { - '24': 'https://example.com/avatar.jpg', - '48': 'https://example.com/avatar-48.jpg', - '96': 'https://example.com/avatar-96.jpg', - }, - }; - mockProviderResult = { destroy: jest.fn(), }; @@ -144,8 +128,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); // Verify that applyChangesToCRDTDoc was called with the record data @@ -163,8 +146,7 @@ describe( 'SyncManager', () => { 'postType/post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); expect( mockProviderCreator ).toHaveBeenCalledTimes( 1 ); @@ -186,8 +168,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); expect( @@ -204,8 +185,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); await manager.load( @@ -213,8 +193,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); // Should only be called once despite two load attempts @@ -235,8 +214,7 @@ describe( 'SyncManager', () => { 'post', '123', record1, - mockHandlers, - mockCurrentUser + mockHandlers ); await manager.load( @@ -244,8 +222,7 @@ describe( 'SyncManager', () => { 'post', '456', record2, - mockHandlers, - mockCurrentUser + mockHandlers ); expect( @@ -293,8 +270,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); // Current record should be applied as changes since the persisted doc does not exist. @@ -329,8 +305,7 @@ describe( 'SyncManager', () => { 'post', '123', record, - mockHandlers, - mockCurrentUser + mockHandlers ); // Changes should NOT be applied since the persisted doc is valid. @@ -369,8 +344,7 @@ describe( 'SyncManager', () => { 'post', '123', record, - mockHandlers, - mockCurrentUser + mockHandlers ); // Changes should be applied for the invalidated properties. @@ -410,8 +384,7 @@ describe( 'SyncManager', () => { 'post', '123', record, - mockHandlers, - mockCurrentUser + mockHandlers ); // Current record should be applied since the persisted doc does not exist. @@ -442,8 +415,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); manager.unload( 'post', '123' ); @@ -467,8 +439,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); manager.unload( 'post', '123' ); @@ -480,8 +451,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); expect( @@ -498,8 +468,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); await manager.load( @@ -507,8 +476,7 @@ describe( 'SyncManager', () => { 'post', '456', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); manager.unload( 'post', '123' ); @@ -546,8 +514,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); jest.clearAllMocks(); @@ -600,8 +567,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); // Get the captured Y.Doc @@ -645,8 +611,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); jest.clearAllMocks(); @@ -694,8 +659,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); // Clear calls of editRecord, which is called during load. @@ -744,8 +708,7 @@ describe( 'SyncManager', () => { 'post', '123', mockRecord, - mockHandlers, - mockCurrentUser + mockHandlers ); // Clear calls of editRecord, which is called during load. diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index aaa8be713f2c77..f3045cb7eb8469 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -12,9 +12,8 @@ import type { Awareness } from 'y-protocols/awareness'; /** * Internal dependencies */ -import type { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from './config'; -import type { WordPressUserInfo } from './awareness/awareness-types'; import type { AwarenessState } from './awareness/awareness-state'; +import type { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from './config'; /* globalThis */ declare global { @@ -78,16 +77,15 @@ export interface SyncConfig { ydoc: Y.Doc, changes: Partial< ObjectData > ) => void; + createAwareness?: ( + ydoc: Y.Doc, + objectId: ObjectID + ) => AwarenessState | undefined; getChangesFromCRDTDoc: ( ydoc: Y.Doc, editedRecord: ObjectData ) => ObjectData; supports?: Record< string, true >; - createAwareness?: ( - ydoc: Y.Doc, - recordHandlers: RecordHandlers, - currentUser: WordPressUserInfo - ) => AwarenessState | undefined; } export interface SyncManager { @@ -104,8 +102,7 @@ export interface SyncManager { objectType: ObjectType, objectId: ObjectID, record: ObjectData, - handlers: RecordHandlers, - currentUser: WordPressUserInfo + handlers: RecordHandlers ) => Promise< void >; // undoManager is undefined until the first entity is loaded. undoManager: SyncUndoManager | undefined; From e8af2461ba77cd264994fbaaedf34d71f3de123c Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 22 Jan 2026 13:54:13 -0700 Subject: [PATCH 27/30] Remove unnecessary exports --- packages/sync/README.md | 8 -------- packages/sync/src/index.ts | 2 -- 2 files changed, 10 deletions(-) diff --git a/packages/sync/README.md b/packages/sync/README.md index bda5bc28363bce..cba9c4e40bf4d2 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -58,14 +58,6 @@ Origin string for CRDT document changes originating from the local editor. Origin string for CRDT document changes originating from the sync manager. -### TypedAwareness - -Extended Awareness class with typed state accessors. - -_Type_ - -- `TypedAwareness` - ### WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE WordPress meta key used to persist the CRDT document for an entity. diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 7e5eb8c04ed203..4fbcf47ca5996b 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -28,6 +28,4 @@ export { WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE, } from './config'; export { createSyncManager } from './manager'; - -export type * from './awareness/awareness-types'; export type * from './types'; From daa079a568ef59c8c924b85c649595463a8046fc Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 22 Jan 2026 13:54:42 -0700 Subject: [PATCH 28/30] Rename getAwarenessInstance => getAwareness for symmetry --- packages/sync/src/manager.ts | 4 ++-- packages/sync/src/types.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sync/src/manager.ts b/packages/sync/src/manager.ts index e040e499be839f..b45025772bb1c6 100644 --- a/packages/sync/src/manager.ts +++ b/packages/sync/src/manager.ts @@ -225,7 +225,7 @@ export function createSyncManager(): SyncManager { * @param {ObjectID} objectId Object ID. * @return {AwarenessState | undefined} The awareness instance, or undefined if not supported. */ - function getAwarenessInstance( + function getAwareness( objectType: ObjectType, objectId: ObjectID ): AwarenessState | undefined { @@ -440,7 +440,7 @@ export function createSyncManager(): SyncManager { return { createMeta: createEntityMeta, - getAwarenessInstance, + getAwareness, load: loadEntity, // Use getter to ensure we always return the current value of `undoManager`. get undoManager(): SyncUndoManager | undefined { diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index f3045cb7eb8469..784c5bc6d71785 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -93,7 +93,7 @@ export interface SyncManager { objectType: ObjectType, objectId: ObjectID ) => Record< string, string >; - getAwarenessInstance: ( + getAwareness: ( objectType: ObjectType, objectId: ObjectID ) => AwarenessState | undefined; From fa964edec81555c9f84a1460a7353a8baaa0cb50 Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 22 Jan 2026 13:55:19 -0700 Subject: [PATCH 29/30] Remove vestial userInfo reference --- packages/sync/src/test/manager.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/sync/src/test/manager.ts b/packages/sync/src/test/manager.ts index 0120fc0ed54ad7..3f34efdb725daf 100644 --- a/packages/sync/src/test/manager.ts +++ b/packages/sync/src/test/manager.ts @@ -39,9 +39,7 @@ import { AwarenessState } from '../awareness/awareness-state'; * A minimal mock awareness class for testing. */ class MockAwarenessState extends AwarenessState { - protected equalityFieldChecks = { - userInfo: () => true, - }; + protected equalityFieldChecks = {}; } // Mock dependencies. From ec820f4b47317e2a0d2d9bc76e5f7274a2d7ee0f Mon Sep 17 00:00:00 2001 From: chriszarate Date: Thu, 22 Jan 2026 13:57:36 -0700 Subject: [PATCH 30/30] Relocate selection types and use YMapWrap --- packages/core-data/src/awareness/types.ts | 2 +- packages/core-data/src/types.ts | 91 --------------- .../src/utils/crdt-user-selections.ts | 107 ++++++++++++++---- 3 files changed, 85 insertions(+), 115 deletions(-) diff --git a/packages/core-data/src/awareness/types.ts b/packages/core-data/src/awareness/types.ts index 79ff9621366037..9e2061a515af92 100644 --- a/packages/core-data/src/awareness/types.ts +++ b/packages/core-data/src/awareness/types.ts @@ -1,8 +1,8 @@ /** * Internal dependencies */ +import type { SelectionState } from '../utils/crdt-user-selections'; import type { User } from '../entity-types'; -import type { SelectionState } from '../types'; export type UserInfo = Pick< User< 'view' >, diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index be71e5434fa049..dac976505a8f2d 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import type { Y } from '@wordpress/sync'; - export interface AnyFunction { ( ...args: any[] ): any; } @@ -18,89 +13,3 @@ 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; diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts index 45ca50dc1706d2..e8362cdc8df339 100644 --- a/packages/core-data/src/utils/crdt-user-selections.ts +++ b/packages/core-data/src/utils/crdt-user-selections.ts @@ -6,18 +6,82 @@ import { Y, CRDT_RECORD_MAP_KEY } from '@wordpress/sync'; /** * Internal dependencies */ -import type { - WPBlockSelection, - SelectionState, - SelectableBlock, - CursorPosition, - SelectionNone, - SelectionCursor, - SelectionInOneBlock, - SelectionInMultipleBlocks, - SelectionWholeBlock, -} from '../types'; -import { SelectionType, type BlockInnerBlocks } from '../types'; +import type { YPostRecord } from './crdt'; +import type { YBlock, YBlocks } from './crdt-blocks'; +import { getRootMap } from './crdt-utils'; +import type { WPBlockSelection } from '../types'; + +/** + * 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; /** * Converts WordPress block editor selection to a SelectionState. @@ -32,8 +96,8 @@ export function getSelectionState( selectionEnd: WPBlockSelection, yDoc: Y.Doc ): SelectionState { - const ydoc = yDoc.getMap( CRDT_RECORD_MAP_KEY ); - const yBlocks = ydoc.get( 'blocks' ) as Y.Array< SelectableBlock >; + const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY ); + const yBlocks = ymap.get( 'blocks' ) ?? new Y.Array< YBlock >(); const isSelectionEmpty = Object.keys( selectionStart ).length === 0; const noSelection: SelectionNone = { @@ -122,7 +186,7 @@ export function getSelectionState( */ function getCursorPosition( selection: WPBlockSelection, - blocks: Y.Array< SelectableBlock > + blocks: YBlocks ): CursorPosition | null { const block = findBlockByClientId( selection.clientId, blocks ); if ( ! block ) { @@ -152,20 +216,17 @@ function getCursorPosition( */ function findBlockByClientId( blockId: string, - blocks: Y.Array< SelectableBlock > -): SelectableBlock | null { + blocks: YBlocks +): YBlock | null { for ( const block of blocks ) { if ( block.get( 'clientId' ) === blockId ) { return block; } - const innerBlocks = block.get( 'innerBlocks' ) as BlockInnerBlocks; + const innerBlocks = block.get( 'innerBlocks' ); - if ( innerBlocks.length > 0 ) { - const innerBlock = findBlockByClientId( - blockId, - block.get( 'innerBlocks' ) as Y.Array< SelectableBlock > - ); + if ( innerBlocks && innerBlocks.length > 0 ) { + const innerBlock = findBlockByClientId( blockId, innerBlocks ); if ( innerBlock ) { return innerBlock;