diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js index 2ac5e181c01d50..1ba5a13364e011 100644 --- a/packages/block-editor/src/store/private-selectors.js +++ b/packages/block-editor/src/store/private-selectors.js @@ -116,6 +116,48 @@ export function isContainerInsertableToInContentOnlyMode( ); } +function getClientIdWithClientIdsTreeUnmemoized( state, clientId ) { + return { + clientId, + innerBlocks: getClientIdsTreeUnmemoized( state, clientId ), + }; +} + +function getClientIdsTreeUnmemoized( state, rootClientId = '' ) { + return getBlockOrder( state, rootClientId ).map( ( clientId ) => + getClientIdWithClientIdsTreeUnmemoized( state, clientId ) + ); +} + +/** + * Returns a stripped down block object containing only its client ID, + * and its inner blocks' client IDs. + * + * @param {Object} state Editor state. + * @param {string} clientId Client ID of the block to get. + * + * @return {Object} Client IDs of the post blocks. + */ +export const getClientIdWithClientIdsTree = createSelector( + getClientIdWithClientIdsTreeUnmemoized, + ( state ) => [ state.blocks.order ] +); + +/** + * Returns the block tree represented in the block-editor store from the + * given root, consisting of stripped down block objects containing only + * their client IDs, and their inner blocks' client IDs. + * + * @param {Object} state Editor state. + * @param {?string} rootClientId Optional root client ID of block list. + * + * @return {Object[]} Client IDs of the post blocks. + */ +export const getClientIdsTree = createSelector( + getClientIdsTreeUnmemoized, + ( state ) => [ state.blocks.order ] +); + function getEnabledClientIdsTreeUnmemoized( state, rootClientId ) { const blockOrder = getBlockOrder( state, rootClientId ); const result = []; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index caf635d45dcfda..740fdd885966d6 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -44,6 +44,8 @@ import { getParentSectionBlock, isZoomOut, isContainerInsertableToInContentOnlyMode, + getClientIdWithClientIdsTree, + getClientIdsTree, } from './private-selectors'; const { isContentBlock } = unlock( blocksPrivateApis ); @@ -225,22 +227,16 @@ export function getBlocks( state, rootClientId ) { * * @return {Object} Client IDs of the post blocks. */ -export const __unstableGetClientIdWithClientIdsTree = createSelector( - ( state, clientId ) => { - deprecated( - "wp.data.select( 'core/block-editor' ).__unstableGetClientIdWithClientIdsTree", - { - since: '6.3', - version: '6.5', - } - ); - return { - clientId, - innerBlocks: __unstableGetClientIdsTree( state, clientId ), - }; - }, - ( state ) => [ state.blocks.order ] -); +export function __unstableGetClientIdWithClientIdsTree( state, clientId ) { + deprecated( + "wp.data.select( 'core/block-editor' ).__unstableGetClientIdWithClientIdsTree", + { + since: '6.3', + version: '6.5', + } + ); + return getClientIdWithClientIdsTree( state, clientId ); +} /** * Returns the block tree represented in the block-editor store from the @@ -254,21 +250,16 @@ export const __unstableGetClientIdWithClientIdsTree = createSelector( * * @return {Object[]} Client IDs of the post blocks. */ -export const __unstableGetClientIdsTree = createSelector( - ( state, rootClientId = '' ) => { - deprecated( - "wp.data.select( 'core/block-editor' ).__unstableGetClientIdsTree", - { - since: '6.3', - version: '6.5', - } - ); - return getBlockOrder( state, rootClientId ).map( ( clientId ) => - __unstableGetClientIdWithClientIdsTree( state, clientId ) - ); - }, - ( state ) => [ state.blocks.order ] -); +export function __unstableGetClientIdsTree( state, rootClientId ) { + deprecated( + "wp.data.select( 'core/block-editor' ).__unstableGetClientIdsTree", + { + since: '6.3', + version: '6.5', + } + ); + return getClientIdsTree( state, rootClientId ); +} /** * Returns an array containing the clientIds of all descendants of the blocks diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index e02d6b485a9cc0..37dd9255037ae0 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -5005,6 +5005,7 @@ describe( '__unstableGetClientIdsTree', () => { }, { clientId: 'baz', innerBlocks: [] }, ] ); + expect( console ).toHaveWarned(); } ); it( "should return the full content tree starting from the root, consisting of stripped down block object containing only its client ID and its inner blocks' client IDs", () => { diff --git a/packages/core-data/src/awareness/block-lookup.ts b/packages/core-data/src/awareness/block-lookup.ts index 6c200bedace6a1..01c2fdc5d2fdf0 100644 --- a/packages/core-data/src/awareness/block-lookup.ts +++ b/packages/core-data/src/awareness/block-lookup.ts @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { select } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { Y } from '@wordpress/sync'; // @ts-ignore No exported types for block editor store selectors. import { store as blockEditorStore } from '@wordpress/block-editor'; @@ -10,17 +10,17 @@ import { store as blockEditorStore } from '@wordpress/block-editor'; * Internal dependencies */ import type { AbsoluteBlockIndexPath } from '../types'; +import { unlock } from '../lock-unlock'; /** - * A block as represented in the block-editor store (from `getBlocks()`). + * A block as represented in the block-editor store's client ID tree. * * This is a minimal interface covering only the fields used by RTC awareness. */ -interface EditorStoreBlock { +export type EditorStoreBlock = { clientId: string; - name: string; innerBlocks: EditorStoreBlock[]; -} +}; /** * Find the block Y.Map that contains a nested Yjs type. @@ -112,26 +112,18 @@ export function getBlockPathInYdoc( * Navigate the block-editor store's block tree by an index path * and return the local block's clientId. * - * In template mode, getBlocks() returns the full template tree, but Yjs - * paths are relative to the post content. This method finds the - * core/post-content block (if present) and uses its inner blocks as the - * navigation root, so paths align with the Yjs document structure. - * - * @param path - The index path, e.g. [0, 1] for blocks[0].innerBlocks[1]. + * @param path - The index path, e.g. [0, 1] for blocks[0].innerBlocks[1]. + * @param blocks - The tree of block-editor store post contentblocks. * @return The local block clientId, or null if the path is invalid. */ export function resolveBlockClientIdByPath( - path: AbsoluteBlockIndexPath + path: AbsoluteBlockIndexPath, + blocks: EditorStoreBlock[] ): string | null { if ( path.length === 0 ) { return null; } - const { getBlocks } = select( blockEditorStore ); - const postContentBlocks = getPostContentBlocks( getBlocks(), getBlocks ); - - let blocks = postContentBlocks; - for ( let i = 0; i < path.length; i++ ) { const block = blocks[ path[ i ] ]; if ( ! block ) { @@ -151,53 +143,20 @@ export function resolveBlockClientIdByPath( * In template mode, the block tree contains template parts wrapping a * core/post-content block. The Yjs document only stores the post content * blocks, so we need to find the core/post-content block and use - * getBlocks(clientId) to retrieve its inner blocks from the store. + * getClientIdsTree(clientId) to retrieve its inner blocks from the store. * - * We must use getBlocks(clientId) rather than reading .innerBlocks from - * the block object because useBlockSync() injects post content as - * controlled inner blocks — they exist in the store's block order map - * but are not populated in the .innerBlocks property of the tree - * returned by getBlocks(). + * Uses the private getClientIdsTree selector which depends only on + * state.blocks.order, avoiding unnecessary re-renders when block + * attributes change (which would happen with getBlocks()). * - * @param rootBlocks - The root-level blocks from getBlocks(). - * @param getBlocks - The getBlocks selector. * @return The blocks that correspond to the Yjs document root. */ -function getPostContentBlocks( - rootBlocks: EditorStoreBlock[], - getBlocks: ( rootClientId?: string ) => EditorStoreBlock[] -): EditorStoreBlock[] { - const postContentBlock = findBlockByName( rootBlocks, 'core/post-content' ); - if ( postContentBlock ) { - // Use getBlocks(clientId) to read controlled inner blocks from - // the store, since postContentBlock.innerBlocks is empty. - return getBlocks( postContentBlock.clientId ); - } - - return rootBlocks; -} - -/** - * Recursively search the block tree for a block with a given name. - * - * @param blocks - The blocks to search. - * @param name - The block name to find. - * @return The first matching block, or null if not found. - */ -function findBlockByName( - blocks: EditorStoreBlock[], - name: string -): EditorStoreBlock | null { - for ( const block of blocks ) { - if ( block.name === name ) { - return block; - } - if ( block.innerBlocks?.length ) { - const found = findBlockByName( block.innerBlocks, name ); - if ( found ) { - return found; - } - } - } - return null; +export function usePostContentBlocks(): EditorStoreBlock[] { + return useSelect( ( select ) => { + const { getBlocksByName, getClientIdsTree } = unlock( + select( blockEditorStore ) + ); + const [ postContentClientId ] = getBlocksByName( 'core/post-content' ); + return getClientIdsTree( postContentClientId ?? '' ); + }, [] ); } diff --git a/packages/core-data/src/awareness/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts index c7cbd843e2c202..af111554eb819b 100644 --- a/packages/core-data/src/awareness/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/post-editor-awareness.ts @@ -37,6 +37,7 @@ import type { WPBlockSelection, } from '../types'; import type { YBlocks } from '../utils/crdt-blocks'; +import type { EditorStoreBlock } from './block-lookup'; import type { DebugCollaboratorData, EditorState, @@ -245,10 +246,12 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { * clientIds (e.g. in "Show Template" mode where blocks are cloned). * * @param selection - The selection state. + * @param blocks - The tree of block-editor store post content blocks. * @return The rich-text offset and block client ID, or nulls if not resolvable. */ public convertSelectionStateToAbsolute( - selection: SelectionState + selection: SelectionState, + blocks: EditorStoreBlock[] ): ResolvedSelection { if ( selection.type === SelectionType.None ) { return { @@ -273,7 +276,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { if ( block instanceof Y.Map ) { const path = getBlockPathInYdoc( block ); localClientId = path - ? resolveBlockClientIdByPath( path ) + ? resolveBlockClientIdByPath( path, blocks ) : null; } } @@ -306,7 +309,9 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { const yType = getContainingBlockYMap( absolutePosition.type ); const path = yType ? getBlockPathInYdoc( yType ) : null; - const localClientId = path ? resolveBlockClientIdByPath( path ) : null; + const localClientId = path + ? resolveBlockClientIdByPath( path, blocks ) + : null; return { richTextOffset: htmlIndexToRichTextOffset( diff --git a/packages/core-data/src/awareness/test/block-lookup.ts b/packages/core-data/src/awareness/test/block-lookup.ts index 2266b67a5918db..dce3588b8f00b8 100644 --- a/packages/core-data/src/awareness/test/block-lookup.ts +++ b/packages/core-data/src/awareness/test/block-lookup.ts @@ -2,7 +2,7 @@ * External dependencies */ import { Y } from '@wordpress/sync'; -import { select } from '@wordpress/data'; +import { renderHook } from '@testing-library/react'; /** * Internal dependencies @@ -11,23 +11,45 @@ import { getBlockPathInYdoc, getContainingBlockYMap, resolveBlockClientIdByPath, + usePostContentBlocks, } from '../block-lookup'; -// Mock WordPress dependencies +import type { EditorStoreBlock } from '../block-lookup'; + +type MockBlock = EditorStoreBlock & { + name: string; + innerBlocks: MockBlock[]; +}; + +let mockGetClientIdsTree: jest.Mock; + +function mockFlattenBlocks( blocks: MockBlock[] ): MockBlock[] { + return blocks.flatMap( ( b ) => [ + b, + ...mockFlattenBlocks( b.innerBlocks ), + ] ); +} + +jest.mock( '../../lock-unlock', () => ( { + unlock: ( obj: any ) => obj, +} ) ); + jest.mock( '@wordpress/data', () => ( { - select: jest.fn(), + useSelect: ( selector: Function ) => + selector( () => ( { + getClientIdsTree: ( ...args: any[] ) => + mockGetClientIdsTree( ...args ), + getBlocksByName: ( blockName: string ) => + mockFlattenBlocks( mockGetClientIdsTree( '' ) ) + .filter( ( b ) => b.name === blockName ) + .map( ( b ) => b.clientId ), + } ) ), } ) ); jest.mock( '@wordpress/block-editor', () => ( { store: 'core/block-editor', } ) ); -type MockBlock = { - clientId: string; - name: string; - innerBlocks: MockBlock[]; -}; - /** * Create a Y.Map block with a clientId and empty innerBlocks, matching the * shape used by the Yjs block tree. @@ -127,40 +149,6 @@ function createNestedYDoc( { return { ydoc, rootBlocks, innerBlocksArray }; } -/** - * Mock the block-editor store's `getBlocks` selector. - * - * When called without an argument (or undefined), returns `rootBlocks`. - * When called with a clientId, looks up the block by clientId and returns - * its innerBlocks. This mimics how `getBlocks( clientId )` works in the - * real store for controlled inner blocks. - * @param rootBlocks - */ -function mockBlockEditorStore( rootBlocks: MockBlock[] ) { - const allBlocks = new Map< string, MockBlock >(); - - function indexBlocks( blocks: MockBlock[] ) { - for ( const block of blocks ) { - allBlocks.set( block.clientId, block ); - if ( block.innerBlocks?.length ) { - indexBlocks( block.innerBlocks ); - } - } - } - indexBlocks( rootBlocks ); - - const getBlocks = jest.fn( ( rootClientId?: string ) => { - if ( rootClientId === undefined ) { - return rootBlocks; - } - const block = allBlocks.get( rootClientId ); - return block ? block.innerBlocks : []; - } ); - - ( select as jest.Mock ).mockReturnValue( { getBlocks } ); - return { getBlocks }; -} - describe( 'getBlockPathInYdoc', () => { it( 'should return path [0] for the first root block', () => { const { blocks } = createFlatYDoc( 3 ); @@ -313,35 +301,30 @@ describe( 'getContainingBlockYMap', () => { } ); describe( 'resolveBlockClientIdByPath', () => { - afterEach( () => { - jest.restoreAllMocks(); - } ); - it( 'should return null for an empty path', () => { - mockBlockEditorStore( [] ); - expect( resolveBlockClientIdByPath( [] ) ).toBeNull(); + expect( resolveBlockClientIdByPath( [], [] ) ).toBeNull(); } ); it( 'should resolve a root block by single-element path', () => { - mockBlockEditorStore( [ + const blocks: MockBlock[] = [ { clientId: 'a', name: 'core/paragraph', innerBlocks: [] }, { clientId: 'b', name: 'core/heading', innerBlocks: [] }, - ] ); + ]; - expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'a' ); - expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBe( 'b' ); + expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe( 'a' ); + expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBe( 'b' ); } ); it( 'should return null for an out-of-bounds index', () => { - mockBlockEditorStore( [ + const blocks: MockBlock[] = [ { clientId: 'a', name: 'core/paragraph', innerBlocks: [] }, - ] ); + ]; - expect( resolveBlockClientIdByPath( [ 5 ] ) ).toBeNull(); + expect( resolveBlockClientIdByPath( [ 5 ], blocks ) ).toBeNull(); } ); it( 'should resolve a nested inner block', () => { - mockBlockEditorStore( [ + const blocks: MockBlock[] = [ { clientId: 'parent', name: 'core/group', @@ -358,13 +341,15 @@ describe( 'resolveBlockClientIdByPath', () => { }, ], }, - ] ); + ]; - expect( resolveBlockClientIdByPath( [ 0, 1 ] ) ).toBe( 'child-1' ); + expect( resolveBlockClientIdByPath( [ 0, 1 ], blocks ) ).toBe( + 'child-1' + ); } ); it( 'should return null when inner path index is out of bounds', () => { - mockBlockEditorStore( [ + const blocks: MockBlock[] = [ { clientId: 'parent', name: 'core/group', @@ -376,12 +361,12 @@ describe( 'resolveBlockClientIdByPath', () => { }, ], }, - ] ); + ]; - expect( resolveBlockClientIdByPath( [ 0, 5 ] ) ).toBeNull(); + expect( resolveBlockClientIdByPath( [ 0, 5 ], blocks ) ).toBeNull(); } ); - describe( 'template mode (getPostContentBlocks behavior)', () => { + describe( 'template mode (usePostContentBlocks behavior)', () => { it( 'should navigate through core/post-content in template mode', () => { const postContentInnerBlocks: MockBlock[] = [ { @@ -398,7 +383,7 @@ describe( 'resolveBlockClientIdByPath', () => { // Template structure: header → post-content → footer. // post-content's innerBlocks are empty in the tree (controlled - // inner blocks), so getBlocks( postContentClientId ) is used. + // inner blocks), so getClientIdsTree( postContentClientId ) is used. const templateBlocks: MockBlock[] = [ { clientId: 'header', @@ -417,13 +402,11 @@ describe( 'resolveBlockClientIdByPath', () => { }, ]; - const { getBlocks } = mockBlockEditorStore( templateBlocks ); - - // Override getBlocks to return post content blocks when called - // with the post-content clientId (mimicking controlled inner - // blocks behavior in useBlockSync). - getBlocks.mockImplementation( ( rootClientId?: string ) => { - if ( rootClientId === undefined ) { + // Override getClientIdsTree to return post content blocks when + // called with the post-content clientId (mimicking controlled + // inner blocks behavior in useBlockSync). + mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => { + if ( rootClientId === '' ) { return templateBlocks; } if ( rootClientId === 'post-content' ) { @@ -432,13 +415,18 @@ describe( 'resolveBlockClientIdByPath', () => { return []; } ); - // Path [0] should resolve to the first post content block, - // not the first template block. - expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'post-para-0' ); - expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBe( 'post-para-1' ); + // The returned blocks should be post content blocks, not the template blocks. + const blocks = renderHook( () => usePostContentBlocks() ).result + .current; + expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe( + 'post-para-0' + ); + expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBe( + 'post-para-1' + ); } ); - it( 'should call getBlocks with post-content clientId', () => { + it( 'should call getClientIdsTree with post-content clientId', () => { const templateBlocks: MockBlock[] = [ { clientId: 'header', @@ -452,9 +440,8 @@ describe( 'resolveBlockClientIdByPath', () => { }, ]; - const { getBlocks } = mockBlockEditorStore( templateBlocks ); - getBlocks.mockImplementation( ( rootClientId?: string ) => { - if ( rootClientId === undefined ) { + mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => { + if ( rootClientId === '' ) { return templateBlocks; } if ( rootClientId === 'pc' ) { @@ -469,10 +456,10 @@ describe( 'resolveBlockClientIdByPath', () => { return []; } ); - resolveBlockClientIdByPath( [ 0 ] ); + renderHook( () => usePostContentBlocks() ); - // Verify getBlocks was called with the post-content clientId. - expect( getBlocks ).toHaveBeenCalledWith( 'pc' ); + // Verify getClientIdsTree was called with the post-content clientId. + expect( mockGetClientIdsTree ).toHaveBeenCalledWith( 'pc' ); } ); it( 'should find core/post-content nested inside template parts', () => { @@ -500,9 +487,8 @@ describe( 'resolveBlockClientIdByPath', () => { }, ]; - const { getBlocks } = mockBlockEditorStore( templateBlocks ); - getBlocks.mockImplementation( ( rootClientId?: string ) => { - if ( rootClientId === undefined ) { + mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => { + if ( rootClientId === '' ) { return templateBlocks; } if ( rootClientId === 'nested-pc' ) { @@ -511,12 +497,16 @@ describe( 'resolveBlockClientIdByPath', () => { return []; } ); - expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'deep-para' ); + const blocks = renderHook( () => usePostContentBlocks() ).result + .current; + expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe( + 'deep-para' + ); } ); it( 'should use root blocks directly when no core/post-content exists', () => { // No template mode — plain post editing. - const blocks: MockBlock[] = [ + const postContentBlocks: MockBlock[] = [ { clientId: 'para-0', name: 'core/paragraph', @@ -529,10 +519,22 @@ describe( 'resolveBlockClientIdByPath', () => { }, ]; - mockBlockEditorStore( blocks ); + mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => { + if ( rootClientId === '' ) { + return postContentBlocks; + } + return []; + } ); - expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'para-0' ); - expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBe( 'para-1' ); + const blocks = renderHook( () => usePostContentBlocks() ).result + .current; + + expect( resolveBlockClientIdByPath( [ 0 ], blocks ) ).toBe( + 'para-0' + ); + expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBe( + 'para-1' + ); } ); it( 'should return null for invalid path in template mode', () => { @@ -549,9 +551,8 @@ describe( 'resolveBlockClientIdByPath', () => { }, ]; - const { getBlocks } = mockBlockEditorStore( templateBlocks ); - getBlocks.mockImplementation( ( rootClientId?: string ) => { - if ( rootClientId === undefined ) { + mockGetClientIdsTree = jest.fn( ( rootClientId: string = '' ) => { + if ( rootClientId === '' ) { return templateBlocks; } if ( rootClientId === 'pc' ) { @@ -567,8 +568,11 @@ describe( 'resolveBlockClientIdByPath', () => { return []; } ); + const blocks = renderHook( () => usePostContentBlocks() ).result + .current; + // Index 1 is out of bounds for the post content blocks. - expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBeNull(); + expect( resolveBlockClientIdByPath( [ 1 ], blocks ) ).toBeNull(); } ); } ); } ); diff --git a/packages/core-data/src/awareness/test/post-editor-awareness.ts b/packages/core-data/src/awareness/test/post-editor-awareness.ts index 3d98ea40768378..3ee59cbfe465b6 100644 --- a/packages/core-data/src/awareness/test/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/test/post-editor-awareness.ts @@ -584,6 +584,14 @@ describe( 'PostEditorAwareness', () => { } ); describe( 'convertSelectionStateToAbsolute', () => { + const defaultEditorBlocks = [ + { + clientId: 'block-1', + name: 'core/paragraph', + innerBlocks: [], + }, + ]; + test( 'should return nulls when relative position cannot be resolved', () => { const awareness = new PostEditorAwareness( doc, @@ -611,8 +619,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + defaultEditorBlocks + ); // Should return nulls when the relative position's type cannot be found expect( result.richTextOffset ).toBeNull(); @@ -651,8 +661,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + defaultEditorBlocks + ); expect( result.richTextOffset ).toBe( 5 ); expect( result.localClientId ).toBe( 'block-1' ); @@ -684,8 +696,10 @@ describe( 'PostEditorAwareness', () => { blockPosition, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + defaultEditorBlocks + ); expect( result.richTextOffset ).toBeNull(); expect( result.localClientId ).toBe( 'block-1' ); @@ -700,9 +714,10 @@ describe( 'PostEditorAwareness', () => { 123 ); - const result = awareness.convertSelectionStateToAbsolute( { - type: SelectionType.None, - } ); + const result = awareness.convertSelectionStateToAbsolute( + { type: SelectionType.None }, + [] + ); expect( result.attributeKey ).toBeNull(); } ); @@ -737,8 +752,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + defaultEditorBlocks + ); expect( result.attributeKey ).toBe( 'body.0.cells.0.content' ); } ); @@ -905,13 +922,23 @@ describe( 'PostEditorAwareness', () => { } ), ] ); - mockBlockEditorStore( { - blocks: [ - { clientId: 'local-0', innerBlocks: [] }, - { clientId: 'local-1', innerBlocks: [] }, - { clientId: 'local-2', innerBlocks: [] }, - ], - } ); + const editorBlocks = [ + { + clientId: 'local-0', + name: 'core/paragraph', + innerBlocks: [], + }, + { + clientId: 'local-1', + name: 'core/paragraph', + innerBlocks: [], + }, + { + clientId: 'local-2', + name: 'core/paragraph', + innerBlocks: [], + }, + ]; const awareness = new PostEditorAwareness( nestedDoc, @@ -942,8 +969,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + editorBlocks + ); expect( result.richTextOffset ).toBe( 2 ); expect( result.localClientId ).toBe( 'local-2' ); @@ -968,17 +997,24 @@ describe( 'PostEditorAwareness', () => { const nestedDoc = createTestDocWithBlocks( [ outerColumn ] ); - mockBlockEditorStore( { - blocks: [ - { - clientId: 'local-outer', - innerBlocks: [ - { clientId: 'local-inner-0', innerBlocks: [] }, - { clientId: 'local-inner-1', innerBlocks: [] }, - ], - }, - ], - } ); + const editorBlocks = [ + { + clientId: 'local-outer', + name: 'core/column', + innerBlocks: [ + { + clientId: 'local-inner-0', + name: 'core/paragraph', + innerBlocks: [], + }, + { + clientId: 'local-inner-1', + name: 'core/paragraph', + innerBlocks: [], + }, + ], + }, + ]; const awareness = new PostEditorAwareness( nestedDoc, @@ -1015,8 +1051,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + editorBlocks + ); expect( result.richTextOffset ).toBe( 5 ); expect( result.localClientId ).toBe( 'local-inner-1' ); @@ -1032,16 +1070,19 @@ describe( 'PostEditorAwareness', () => { const nestedDoc = createTestDocWithBlocks( [ outerColumn ] ); - mockBlockEditorStore( { - blocks: [ - { - clientId: 'local-col', - innerBlocks: [ - { clientId: 'local-img', innerBlocks: [] }, - ], - }, - ], - } ); + const editorBlocks = [ + { + clientId: 'local-col', + name: 'core/column', + innerBlocks: [ + { + clientId: 'local-img', + name: 'core/image', + innerBlocks: [], + }, + ], + }, + ]; const awareness = new PostEditorAwareness( nestedDoc, @@ -1070,8 +1111,10 @@ describe( 'PostEditorAwareness', () => { blockPosition, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + editorBlocks + ); expect( result.richTextOffset ).toBeNull(); expect( result.localClientId ).toBe( 'local-img' ); @@ -1103,29 +1146,35 @@ describe( 'PostEditorAwareness', () => { outerColumns1, ] ); - mockBlockEditorStore( { - blocks: [ - { clientId: 'local-outer-0', innerBlocks: [] }, - { - clientId: 'local-outer-1', - innerBlocks: [ - { - clientId: 'local-mid', - innerBlocks: [ - { - clientId: 'local-deep-0', - innerBlocks: [], - }, - { - clientId: 'local-deep-1', - innerBlocks: [], - }, - ], - }, - ], - }, - ], - } ); + const editorBlocks = [ + { + clientId: 'local-outer-0', + name: 'core/columns', + innerBlocks: [], + }, + { + clientId: 'local-outer-1', + name: 'core/columns', + innerBlocks: [ + { + clientId: 'local-mid', + name: 'core/column', + innerBlocks: [ + { + clientId: 'local-deep-0', + name: 'core/paragraph', + innerBlocks: [], + }, + { + clientId: 'local-deep-1', + name: 'core/paragraph', + innerBlocks: [], + }, + ], + }, + ], + }, + ]; const awareness = new PostEditorAwareness( nestedDoc, @@ -1164,8 +1213,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + editorBlocks + ); expect( result.richTextOffset ).toBe( 7 ); expect( result.localClientId ).toBe( 'local-deep-1' ); @@ -1175,6 +1226,14 @@ describe( 'PostEditorAwareness', () => { } ); describe( 'convertSelectionStateToAbsolute with nested rich-text attributes', () => { + const editorBlocks = [ + { + clientId: 'local-nested-attrs', + name: 'test/nested-rich-text', + innerBlocks: [], + }, + ]; + test.each( NESTED_SELECTION_SEEDS )( 'resolves fuzzed nested rich-text cursor (seed %i)', ( seed ) => { @@ -1185,15 +1244,6 @@ describe( 'PostEditorAwareness', () => { ); const nestedDoc = createTestDocWithBlocks( [ block ] ); - mockBlockEditorStore( { - blocks: [ - { - clientId: 'local-nested-attrs', - innerBlocks: [], - }, - ], - } ); - const target = rng.pick( targets ); const initialOffset = rng.intBetween( 1, @@ -1233,8 +1283,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + editorBlocks + ); expect( result.richTextOffset ).toBe( expectedOffset ); expect( result.localClientId ).toBe( 'local-nested-attrs' ); @@ -1244,9 +1296,8 @@ describe( 'PostEditorAwareness', () => { ); } ); - describe( 'template mode (core/post-content handling)', () => { - test( 'should resolve cursor when getBlocks returns template tree with core/post-content', () => { - // Yjs doc has only the post content blocks (no template wrapper) + describe( 'post content blocks resolution', () => { + test( 'should resolve cursor with post content blocks', () => { const templateDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-para-0', 'core/paragraph', { textContent: 'Post paragraph 1', @@ -1256,55 +1307,20 @@ describe( 'PostEditorAwareness', () => { } ), ] ); - // In template mode, getBlocks() returns the full template tree. - // The Yjs paths are relative to post content, so the receiver needs - // to find core/post-content and navigate from there. - const postContentClientId = 'local-post-content'; - const mockGetBlocks = jest - .fn() - .mockImplementation( ( rootClientId?: string ) => { - if ( rootClientId === postContentClientId ) { - // Controlled inner blocks of core/post-content - return [ - { - clientId: 'local-para-0', - name: 'core/paragraph', - innerBlocks: [], - }, - { - clientId: 'local-para-1', - name: 'core/paragraph', - innerBlocks: [], - }, - ]; - } - // Full template tree - return [ - { - clientId: 'local-header', - name: 'core/template-part', - innerBlocks: [], - }, - { - clientId: 'local-group', - name: 'core/group', - innerBlocks: [ - { - clientId: postContentClientId, - name: 'core/post-content', - innerBlocks: [], // empty because they're controlled inner blocks - }, - ], - }, - { - clientId: 'local-footer', - name: 'core/template-part', - innerBlocks: [], - }, - ]; - } ); - - mockBlockEditorStore( { getBlocks: mockGetBlocks } ); + // The caller provides post content blocks directly + // (template detection is handled by usePostContentBlocks). + const postContentBlocks = [ + { + clientId: 'local-para-0', + name: 'core/paragraph', + innerBlocks: [], + }, + { + clientId: 'local-para-1', + name: 'core/paragraph', + innerBlocks: [], + }, + ]; const awareness = new PostEditorAwareness( templateDoc, @@ -1335,55 +1351,29 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + postContentBlocks + ); expect( result.richTextOffset ).toBe( 4 ); - // Should resolve to the post-content inner block, not a template block expect( result.localClientId ).toBe( 'local-para-1' ); - // Verify getBlocks was called with the post-content clientId - expect( mockGetBlocks ).toHaveBeenCalledWith( postContentClientId ); templateDoc.destroy(); } ); - test( 'should resolve WholeBlock in template mode', () => { + test( 'should resolve WholeBlock with post content blocks', () => { const templateDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-img', 'core/image' ), ] ); - const postContentClientId = 'local-post-content'; - const mockGetBlocks = jest - .fn() - .mockImplementation( ( rootClientId?: string ) => { - if ( rootClientId === postContentClientId ) { - return [ - { - clientId: 'local-img', - name: 'core/image', - innerBlocks: [], - }, - ]; - } - return [ - { - clientId: 'local-group', - name: 'core/group', - innerBlocks: [ - { - clientId: postContentClientId, - name: 'core/post-content', - innerBlocks: [], - }, - ], - }, - ]; - } ); - - mockBlockEditorStore( { - getBlocks: mockGetBlocks, - getBlockName: 'core/image', - } ); + const postContentBlocks = [ + { + clientId: 'local-img', + name: 'core/image', + innerBlocks: [], + }, + ]; const awareness = new PostEditorAwareness( templateDoc, @@ -1407,8 +1397,10 @@ describe( 'PostEditorAwareness', () => { blockPosition, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + postContentBlocks + ); expect( result.richTextOffset ).toBeNull(); expect( result.localClientId ).toBe( 'local-img' ); @@ -1416,17 +1408,20 @@ describe( 'PostEditorAwareness', () => { templateDoc.destroy(); } ); - test( 'should fall back to root blocks when no core/post-content exists', () => { - // Normal mode (no template) — should use root blocks directly + test( 'should resolve with root blocks directly', () => { const normalDoc = createTestDocWithBlocks( [ createYBlock( 'yjs-para', 'core/paragraph', { textContent: 'Normal mode', } ), ] ); - mockBlockEditorStore( { - blocks: [ { clientId: 'local-para', innerBlocks: [] } ], - } ); + const editorBlocks = [ + { + clientId: 'local-para', + name: 'core/paragraph', + innerBlocks: [], + }, + ]; const awareness = new PostEditorAwareness( normalDoc, @@ -1456,8 +1451,10 @@ describe( 'PostEditorAwareness', () => { }, }; - const result = - awareness.convertSelectionStateToAbsolute( selection ); + const result = awareness.convertSelectionStateToAbsolute( + selection, + editorBlocks + ); expect( result.richTextOffset ).toBe( 3 ); expect( result.localClientId ).toBe( 'local-para' ); diff --git a/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts index c1c49796252684..72d17bf3795294 100644 --- a/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts @@ -28,6 +28,14 @@ jest.mock( '../../sync', () => ( { getSyncManager: jest.fn(), } ) ); +const mockPostContentBlocks = [ + { clientId: 'block-1', name: 'core/paragraph', innerBlocks: [] }, +]; + +jest.mock( '../../awareness/block-lookup', () => ( { + usePostContentBlocks: jest.fn( () => mockPostContentBlocks ), +} ) ); + const mockAvatarUrls = { '24': 'https://example.com/avatar-24.png', '48': 'https://example.com/avatar-48.png', @@ -299,7 +307,7 @@ describe( 'use-post-editor-awareness-state hooks', () => { } ); } ); - test( 'should call awareness.convertSelectionStateToAbsolute with selection', () => { + test( 'should call awareness.convertSelectionStateToAbsolute with selection and blocks', () => { const mockSelection: SelectionCursor = { type: SelectionType.Cursor, cursorPosition: { @@ -321,7 +329,7 @@ describe( 'use-post-editor-awareness-state hooks', () => { expect( mockAwareness.convertSelectionStateToAbsolute - ).toHaveBeenCalledWith( mockSelection ); + ).toHaveBeenCalledWith( mockSelection, mockPostContentBlocks ); expect( position ).toEqual( { richTextOffset: 10, localClientId: 'block-1', diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index e4b2b7090f5a08..631f0075c37194 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -2,13 +2,15 @@ * External dependencies */ import { usePrevious } from '@wordpress/compose'; -import { useEffect, useState } from '@wordpress/element'; +import { useEffect, useState, useCallback } from '@wordpress/element'; import type { Y } from '@wordpress/sync'; /** * Internal dependencies */ import { getSyncManager } from '../sync'; +import { usePostContentBlocks } from '../awareness/block-lookup'; +import type { EditorStoreBlock } from '../awareness/block-lookup'; import type { PostEditorAwarenessState as ActiveCollaborator, PostSaveEvent, @@ -19,7 +21,10 @@ import type { PostEditorAwareness } from '../awareness/post-editor-awareness'; interface AwarenessState { activeCollaborators: ActiveCollaborator[]; - resolveSelection: ( selection: SelectionState ) => ResolvedSelection; + resolveSelection: ( + selection: SelectionState, + blocks: EditorStoreBlock[] + ) => ResolvedSelection; getDebugData: () => YDocDebugData; isCurrentCollaboratorDisconnected: boolean; } @@ -49,8 +54,10 @@ function getAwarenessState( return { activeCollaborators, - resolveSelection: ( selection: SelectionState ) => - awareness.convertSelectionStateToAbsolute( selection ), + resolveSelection: ( + selection: SelectionState, + blocks: EditorStoreBlock[] + ) => awareness.convertSelectionStateToAbsolute( selection, blocks ), getDebugData: () => awareness.getDebugData(), isCurrentCollaboratorDisconnected: activeCollaborators.find( ( collaborator ) => collaborator.isMe ) @@ -124,7 +131,13 @@ export function useResolvedSelection( postId: number | null, postType: string | null ): ( selection: SelectionState ) => ResolvedSelection { - return usePostEditorAwarenessState( postId, postType ).resolveSelection; + const blocks = usePostContentBlocks(); + const awarenessState = usePostEditorAwarenessState( postId, postType ); + return useCallback( + ( selection: SelectionState ) => + awarenessState.resolveSelection( selection, blocks ), + [ blocks, awarenessState ] + ); } /** diff --git a/packages/editor/src/components/collaborators-overlay/use-block-highlighting.ts b/packages/editor/src/components/collaborators-overlay/use-block-highlighting.ts index 9904fbcf14faa6..36c8452c4f5652 100644 --- a/packages/editor/src/components/collaborators-overlay/use-block-highlighting.ts +++ b/packages/editor/src/components/collaborators-overlay/use-block-highlighting.ts @@ -1,14 +1,11 @@ /** * WordPress dependencies */ -// @ts-expect-error No exported types -import { store as blockEditorStore } from '@wordpress/block-editor'; import { privateApis as coreDataPrivateApis, type CoreDataPrivateApis, type PostEditorAwarenessState as ActiveCollaborator, } from '@wordpress/core-data'; -import { useSelect } from '@wordpress/data'; import { useEffect, useRef, useState } from '@wordpress/element'; /** @@ -74,11 +71,6 @@ export function useBlockHighlighting( const [ recomputeToken, rerenderHighlightsAfterDelay ] = useDebouncedRecompute( delayMs ); - const blockClientIds = useSelect( - ( select ) => select( blockEditorStore ).getClientIdsWithDescendants(), - [] - ); - // All DOM mutations and position computations live inside useEffect. useEffect( () => { if ( ! blockEditorDocument ) { @@ -215,7 +207,6 @@ export function useBlockHighlighting( overlayElement, recomputeToken, resolveSelection, - blockClientIds, ] ); return { highlights, rerenderHighlightsAfterDelay }; diff --git a/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts b/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts index 310a888a2fdbb1..872903d1ec7071 100644 --- a/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts +++ b/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts @@ -1,5 +1,3 @@ -// @ts-expect-error No exported types -import { store as blockEditorStore } from '@wordpress/block-editor'; import { privateApis as coreDataPrivateApis } from '@wordpress/core-data'; import type { CoreDataPrivateApis, @@ -70,11 +68,6 @@ export function useRenderCursors( [] ); - const blockClientIds = useSelect( - ( select ) => select( blockEditorStore ).getClientIdsWithDescendants(), - [] - ); - const [ cursorPositions, setCursorPositions ] = useState< CursorData[] >( [] ); @@ -186,7 +179,6 @@ export function useRenderCursors( sortedUsers, showOwnCursor, recomputeToken, - blockClientIds, ] ); return { cursors: cursorPositions, rerenderCursorsAfterDelay };