Skip to content
Merged
12 changes: 5 additions & 7 deletions packages/core-data/src/awareness/base-awareness.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* WordPress dependencies
*/
import { select } from '@wordpress/data';
import { resolveSelect } from '@wordpress/data';
import { AwarenessState } from '@wordpress/sync';

/**
Expand All @@ -15,16 +15,14 @@ import type { BaseState } from './types';
export abstract class BaseAwarenessState<
State extends BaseState,
> extends AwarenessState< State > {
public setUp(): void {
super.setUp();

this.setCurrentUserInfo();
protected onSetUp(): void {
void this.setCurrentUserInfo();
}

/**
* Set the current user info in the local state.
*/
private setCurrentUserInfo(): void {
private async setCurrentUserInfo(): Promise< void > {
const states = this.getStates();
const otherUserColors = Array.from( states.entries() )
.filter(
Expand All @@ -35,7 +33,7 @@ export abstract class BaseAwarenessState<
.filter( Boolean );

// Get current user info and set it in local state.
const currentUser = select( coreStore ).getCurrentUser();
const currentUser = await resolveSelect( coreStore ).getCurrentUser();
const userInfo = generateUserInfo( currentUser, otherUserColors );
this.setLocalStateField( 'userInfo', userInfo );
}
Expand Down
113 changes: 108 additions & 5 deletions packages/core-data/src/awareness/post-editor-awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { dispatch, select, subscribe } from '@wordpress/data';
import type { Y } from '@wordpress/sync';
import { Y } from '@wordpress/sync';
// @ts-ignore No exported types for block editor store selectors.
import { store as blockEditorStore } from '@wordpress/block-editor';

Expand All @@ -20,8 +20,14 @@ import {
getSelectionState,
} from '../utils/crdt-user-selections';

import type { WPBlockSelection } from '../types';
import type { EditorState, PostEditorState } from './types';
import type { SelectionCursor, WPBlockSelection } from '../types';
import type {
DebugUserData,
EditorState,
PostEditorState,
SerializableYItem,
YDocDebugData,
} from './types';

export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
protected equalityFieldChecks = {
Expand All @@ -38,8 +44,8 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
super( doc );
}

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

this.subscribeToUserSelectionChanges();
}
Expand Down Expand Up @@ -164,4 +170,101 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {

return areSelectionsStatesEqual( state1.selection, state2.selection );
}

/**
* Get the absolute position index from a selection cursor.
*
* @param selection - The selection cursor.
* @return The absolute position index, or null if not found.
*/
public getAbsolutePositionIndex(
selection: SelectionCursor
): number | null {
return (
Y.createAbsolutePositionFromRelativePosition(
selection.cursorPosition.relativePosition,
this.doc
)?.index ?? null
);
}

/**
* Type guard to check if a struct is a Y.Item (not Y.GC)
* @param struct - The struct to check.
* @return True if the struct is a Y.Item, false otherwise.
*/
private isYItem( struct: Y.Item | Y.GC ): struct is Y.Item {
return 'content' in struct;
}

/**
* Get data for debugging, using the awareness state.
*
* @return {YDocDebugData} The debug data.
*/
public getDebugData(): YDocDebugData {
const ydoc = this.doc;

// Manually extract doc data to avoid deprecated toJSON method
const docData: Record< string, unknown > = Object.fromEntries(
Array.from( ydoc.share, ( [ key, value ] ) => [
key,
value.toJSON(),
] )
);

// Build userMap from awareness store (all users seen this session)
const userMapData = new Map< string, DebugUserData >(
Array.from( this.getSeenStates().entries() ).map(
( [ clientId, userState ] ) => [
String( clientId ),
{
name: userState.userInfo.name,
wpUserId: userState.userInfo.id,
},
]
)
);

// Serialize Yjs client items to avoid deep nesting
const serializableClientItems: Record<
number,
Array< SerializableYItem >
> = {};

ydoc.store.clients.forEach( ( structs, clientId ) => {
// Filter for Y.Item only (skip Y.GC garbage collection structs)
const items = structs.filter( this.isYItem );

serializableClientItems[ clientId ] = items.map( ( item ) => {
const { left, right, ...rest } = item;

return {
...rest,
left: left
? {
id: left.id,
length: left.length,
origin: left.origin,
content: left.content,
}
: null,
right: right
? {
id: right.id,
length: right.length,
origin: right.origin,
content: right.content,
}
: null,
};
} );
} );

return {
doc: docData,
clients: serializableClientItems,
userMap: Object.fromEntries( userMapData ),
};
}
}
47 changes: 46 additions & 1 deletion packages/core-data/src/awareness/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* WordPress dependencies
*/
import type { EnhancedState, Y } from '@wordpress/sync';

/**
* Internal dependencies
*/
import type { SelectionState } from '../utils/crdt-user-selections';
import type { SelectionState } from '../types';
import type { User } from '../entity-types';

export type UserInfo = Pick<
Expand Down Expand Up @@ -36,3 +41,43 @@ export interface EditorState {
export interface PostEditorState extends BaseState {
editorState?: EditorState;
}

/**
* An enhanced post editor awareness state includes additional metadata about
* the user and their connection.
*/
export type PostEditorAwarenessState = EnhancedState< PostEditorState >;

// WordPress user info for debug export (subset of UserInfo)
export type DebugUserData = Pick< UserInfo, 'name' > & {
wpUserId: UserInfo[ 'id' ];
};

export interface YDocDebugData {
doc: Record< string, unknown >;
clients: Record< number, Array< SerializableYItem > >;
userMap: Record< string, DebugUserData >;
}

// Type for serializable left/right item references to avoid deep nesting
export type SerializableYItemRef = Pick<
Y.Item,
'id' | 'length' | 'origin' | 'content'
>;

// Serializable Y.Item - only includes data properties with shallow left/right references
export type SerializableYItem = Pick<
Y.Item,
| 'id'
| 'length'
| 'origin'
| 'rightOrigin'
| 'parent'
| 'parentSub'
| 'redone'
| 'content'
| 'info'
> & {
left: SerializableYItemRef | null;
right: SerializableYItemRef | null;
};
148 changes: 148 additions & 0 deletions packages/core-data/src/hooks/use-post-editor-awareness-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import { getSyncManager } from '../sync';
import type {
PostEditorAwarenessState as ActiveUser,
YDocDebugData,
} from '../awareness/types';
import type { SelectionCursor } from '../types';
import type { PostEditorAwareness } from '../awareness/post-editor-awareness';

interface AwarenessState {
activeUsers: ActiveUser[];
getAbsolutePositionIndex: ( selection: SelectionCursor ) => number | null;
getDebugData: () => YDocDebugData;
isCurrentUserDisconnected: boolean;
}

const defaultState: AwarenessState = {
activeUsers: [],
getAbsolutePositionIndex: () => null,
getDebugData: () => ( {
doc: {},
clients: {},
userMap: {},
} ),
isCurrentUserDisconnected: false,
};

function getAwarenessState(
awareness: PostEditorAwareness,
newState?: ActiveUser[]
): AwarenessState {
const activeUsers = newState ?? awareness.getCurrentState();

return {
activeUsers,
getAbsolutePositionIndex: ( selection: SelectionCursor ) =>
awareness.getAbsolutePositionIndex( selection ),
getDebugData: () => awareness.getDebugData(),
isCurrentUserDisconnected:
activeUsers.find( ( user ) => user.isMe )?.isConnected === false,
};
}

function usePostEditorAwarenessState(
postId: number | null,
postType: string | null
): AwarenessState {
const [ state, setState ] = useState< AwarenessState >( defaultState );

useEffect( () => {
if ( null === postId || null === postType ) {
setState( defaultState );
return;
}

const objectType = `postType/${ postType }`;
const objectId = postId.toString();
const awareness = getSyncManager()?.getAwareness< PostEditorAwareness >(
objectType,
objectId
);

if ( ! awareness ) {
setState( defaultState );
return;
}

awareness.setUp();

// Initialize with current awareness state.
setState( getAwarenessState( awareness ) );

const unsubscribe = awareness?.onStateChange(
( newState: ActiveUser[] ) => {
setState( getAwarenessState( awareness, newState ) );
}
);

return unsubscribe;
}, [ postId, postType ] );

return state;
}

/**
* Hook to get the active users for a post editor.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return {ActiveUser[]} The active users.
*/
export function useActiveUsers(
postId: number | null,
postType: string | null
): ActiveUser[] {
return usePostEditorAwarenessState( postId, postType ).activeUsers;
}

/**
* Hook to get the absolute position index for a post editor.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return {SelectionCursor} The absolute position index.
*/
export function useGetAbsolutePositionIndex(
postId: number | null,
postType: string | null
): ( selection: SelectionCursor ) => number | null {
return usePostEditorAwarenessState( postId, postType )
.getAbsolutePositionIndex;
}

/**
* Hook to get data for debugging, using the awareness state.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return {YDocDebugData} The debug data.
*/
export function useGetDebugData(
postId: number | null,
postType: string | null
): YDocDebugData {
return usePostEditorAwarenessState( postId, postType ).getDebugData();
}

/**
* Hook to check if the current user is disconnected.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return {boolean} Whether the current user is disconnected.
*/
export function useIsDisconnected(
postId: number | null,
postType: string | null
): boolean {
return usePostEditorAwarenessState( postId, postType )
.isCurrentUserDisconnected;
}
Loading
Loading