@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
388 lines (342 loc) • 10.8 kB
text/typescript
import { usePrevious } from '@wordpress/compose';
import { useEffect, useState, useCallback } from '@wordpress/element';
import type { Y } from '@wordpress/sync';
import { getSyncManager } from '../sync';
import { usePostContentBlocks } from '../awareness/block-lookup';
import type { EditorStoreBlock } from '../awareness/block-lookup';
import type {
PostEditorAwarenessState as ActiveCollaborator,
PostSaveEvent,
YDocDebugData,
} from '../awareness/types';
import type { SelectionState, ResolvedSelection } from '../types';
import type { PostEditorAwareness } from '../awareness/post-editor-awareness';
interface AwarenessState {
activeCollaborators: ActiveCollaborator[];
resolveSelection: (
selection: SelectionState,
blocks: EditorStoreBlock[]
) => ResolvedSelection;
getDebugData: () => YDocDebugData;
isCurrentCollaboratorDisconnected: boolean;
}
const defaultResolvedSelection: ResolvedSelection = {
richTextOffset: null,
localClientId: null,
attributeKey: null,
};
const defaultState: AwarenessState = {
activeCollaborators: [],
resolveSelection: () => defaultResolvedSelection,
getDebugData: () => ( {
doc: {},
clients: {},
collaboratorMap: {},
} ),
isCurrentCollaboratorDisconnected: false,
};
function getAwarenessState(
awareness: PostEditorAwareness,
newState?: ActiveCollaborator[]
): AwarenessState {
const activeCollaborators = newState ?? awareness.getCurrentState();
return {
activeCollaborators,
resolveSelection: (
selection: SelectionState,
blocks: EditorStoreBlock[]
) => awareness.convertSelectionStateToAbsolute( selection, blocks ),
getDebugData: () => awareness.getDebugData(),
isCurrentCollaboratorDisconnected:
activeCollaborators.find( ( collaborator ) => collaborator.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: ActiveCollaborator[] ) => {
setState( getAwarenessState( awareness, newState ) );
}
);
return unsubscribe;
}, [ postId, postType ] );
return state;
}
/**
* Hook to get the active collaborators for a post editor.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return {ActiveCollaborator[]} The active collaborators.
*/
export function useActiveCollaborators(
postId: number | null,
postType: string | null
): ActiveCollaborator[] {
return usePostEditorAwarenessState( postId, postType ).activeCollaborators;
}
/**
* Hook to resolve a selection state to a text index and block client ID.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return A function that resolves a selection to its text index and block client ID.
*/
export function useResolvedSelection(
postId: number | null,
postType: string | null
): ( selection: SelectionState ) => ResolvedSelection {
const blocks = usePostContentBlocks();
const awarenessState = usePostEditorAwarenessState( postId, postType );
return useCallback(
( selection: SelectionState ) =>
awarenessState.resolveSelection( selection, blocks ),
[ blocks, awarenessState ]
);
}
/**
* 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 collaborator is disconnected.
*
* @param postId - The ID of the post.
* @param postType - The type of the post.
* @return {boolean} Whether the current collaborator is disconnected.
*/
export function useIsDisconnected(
postId: number | null,
postType: string | null
): boolean {
return usePostEditorAwarenessState( postId, postType )
.isCurrentCollaboratorDisconnected;
}
/**
* Hook that subscribes to the CRDT state map and returns the most recent
* user-facing post save event (timestamp + client ID). The state map is
* updated by `markEntityAsSaved` in `@wordpress/sync`.
*
* @param postId The ID of the post.
* @param postType The type of the post.
*/
function useLastPostSave(
postId: number | null,
postType: string | null
): PostSaveEvent | null {
const [ lastSave, setLastSave ] = useState< PostSaveEvent | null >( null );
useEffect( () => {
if ( null === postId || null === postType ) {
setLastSave( null );
return;
}
const awareness = getSyncManager()?.getAwareness< PostEditorAwareness >(
`postType/${ postType }`,
postId.toString()
);
if ( ! awareness ) {
setLastSave( null );
return;
}
awareness.setUp();
const stateMap = awareness.doc.getMap( 'state' );
const recordMap = awareness.doc.getMap( 'document' );
// Only notify for saves that occur after the observer is
// set up. This prevents false notifications when the Y.Doc
// syncs historical state on page load or peer reconnect.
const setupTime = Date.now();
const observer = ( event: Y.YMapEvent< unknown > ) => {
if ( event.keysChanged.has( 'savedAt' ) ) {
const savedAt = stateMap.get( 'savedAt' ) as number;
const savedByClientId = stateMap.get( 'savedBy' ) as number;
if (
typeof savedAt === 'number' &&
typeof savedByClientId === 'number' &&
savedAt > setupTime
) {
const postStatus = recordMap.get( 'status' ) as
| string
| undefined;
setLastSave( { savedAt, savedByClientId, postStatus } );
}
}
};
stateMap.observe( observer );
return () => {
stateMap.unobserve( observer );
};
}, [ postId, postType ] );
return lastSave;
}
/**
* Hook that fires a callback when a new collaborator joins the post.
* Handles initial hydration and state diffing internally—consumers
* only receive "join" events for collaborators that appear after the
* initial state has loaded.
*
* The callback receives the joining collaborator and, when available,
* the current user's state (useful for comparing `enteredAt` timestamps).
*
* @param postId The ID of the post.
* @param postType The type of the post.
* @param callback Invoked for each collaborator that joins.
*/
export function useOnCollaboratorJoin(
postId: number | null,
postType: string | null,
callback: (
collaborator: ActiveCollaborator,
me?: ActiveCollaborator
) => void
): void {
const { activeCollaborators } = usePostEditorAwarenessState(
postId,
postType
);
const prevCollaborators = usePrevious( activeCollaborators );
useEffect( () => {
/*
* On first render usePrevious returns undefined. On subsequent
* renders the list may still be empty while the store hydrates.
* In both cases, skip to avoid spurious "joined" callbacks for
* users already present.
*/
if ( ! prevCollaborators || prevCollaborators.length === 0 ) {
return;
}
const prevMap = new Map< number, ActiveCollaborator >(
prevCollaborators.map( ( collaborator ) => [
collaborator.clientId,
collaborator,
] )
);
const me = activeCollaborators.find(
( collaborator ) => collaborator.isMe
);
for ( const collaborator of activeCollaborators ) {
if (
! prevMap.has( collaborator.clientId ) &&
! collaborator.isMe
) {
callback( collaborator, me );
}
}
}, [ activeCollaborators, prevCollaborators, callback ] );
}
/**
* Hook that fires a callback when a collaborator leaves the post.
* A "leave" is detected when a previously-connected collaborator either
* transitions to `isConnected = false` or disappears from the list
* entirely while still connected. Already-disconnected collaborators
* that are later removed from the list are silently ignored.
*
* @param postId The ID of the post.
* @param postType The type of the post.
* @param callback Invoked for each collaborator that leaves.
*/
export function useOnCollaboratorLeave(
postId: number | null,
postType: string | null,
callback: ( collaborator: ActiveCollaborator ) => void
): void {
const { activeCollaborators } = usePostEditorAwarenessState(
postId,
postType
);
const prevCollaborators = usePrevious( activeCollaborators );
useEffect( () => {
if ( ! prevCollaborators || prevCollaborators.length === 0 ) {
return;
}
const newMap = new Map< number, ActiveCollaborator >(
activeCollaborators.map( ( collaborator ) => [
collaborator.clientId,
collaborator,
] )
);
for ( const prevCollab of prevCollaborators ) {
if ( prevCollab.isMe || ! prevCollab.isConnected ) {
continue;
}
const newCollab = newMap.get( prevCollab.clientId );
if ( ! newCollab?.isConnected ) {
callback( prevCollab );
}
}
}, [ activeCollaborators, prevCollaborators, callback ] );
}
/**
* Hook that fires a callback when a remote collaborator saves the post.
* Only fires for saves by other collaborators (not the current user).
* Deduplicates by `savedAt` timestamp so the same save event is never
* reported twice.
*
* @param postId The ID of the post.
* @param postType The type of the post.
* @param callback Invoked with the save event, the collaborator who saved,
* and the previous save event (if any) for transition detection.
*/
export function useOnPostSave(
postId: number | null,
postType: string | null,
callback: (
event: PostSaveEvent,
saver: ActiveCollaborator,
prevEvent: PostSaveEvent | null
) => void
): void {
const { activeCollaborators } = usePostEditorAwarenessState(
postId,
postType
);
const lastPostSave = useLastPostSave( postId, postType );
const prevPostSave = usePrevious( lastPostSave );
useEffect( () => {
if ( ! lastPostSave ) {
return;
}
if ( prevPostSave && lastPostSave.savedAt === prevPostSave.savedAt ) {
return;
}
const saver = activeCollaborators.find(
( collaborator ) =>
collaborator.clientId === lastPostSave.savedByClientId &&
! collaborator.isMe
);
if ( ! saver ) {
return;
}
callback( lastPostSave, saver, prevPostSave ?? null );
}, [ lastPostSave, prevPostSave, activeCollaborators, callback ] );
}