@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
1,186 lines (1,015 loc) • 28.9 kB
text/typescript
/**
* External dependencies
*/
import { Y } from '@wordpress/sync';
import { dispatch, select, subscribe, resolveSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { PostEditorAwareness } from '../post-editor-awareness';
import { SelectionType } from '../../utils/crdt-user-selections';
import type {
SelectionNone,
SelectionCursor,
SelectionWholeBlock,
} from '../../types';
import { CRDT_RECORD_MAP_KEY } from '../../sync';
import type { CollaboratorInfo } from '../types';
// Mock WordPress dependencies
jest.mock( '@wordpress/data', () => ( {
dispatch: jest.fn(),
select: jest.fn(),
subscribe: jest.fn(),
resolveSelect: jest.fn(),
} ) );
jest.mock( '@wordpress/block-editor', () => ( {
store: 'core/block-editor',
} ) );
// Mock window.navigator.userAgent
const mockUserAgent = ( userAgent: string ) => {
Object.defineProperty( window.navigator, 'userAgent', {
value: userAgent,
configurable: true,
} );
};
const mockAvatarUrls = {
'24': 'https://example.com/avatar-24.png',
'48': 'https://example.com/avatar-48.png',
'96': 'https://example.com/avatar-96.png',
};
const createMockUser = () => ( {
id: 1,
name: 'Test User',
slug: 'test-user',
avatar_urls: mockAvatarUrls,
} );
type MockBlock = {
clientId: string;
name?: string;
innerBlocks: MockBlock[];
};
interface MockBlockEditorOverrides {
blocks?: MockBlock[];
getBlocks?: jest.Mock;
getBlockName?: string;
getSelectionStart?: jest.Mock;
getSelectionEnd?: jest.Mock;
}
/**
* Mock the block-editor store selectors returned by `select( blockEditorStore )`.
*
* Only the fields that vary between tests need to be passed — everything else
* gets sensible defaults. Pass `blocks` for the common case (static return
* value) or `getBlocks` when you need `mockImplementation` (e.g. template mode).
*
* Returns `{ getBlocks }` so callers can assert on it (e.g. `toHaveBeenCalledWith`).
*
* @param overrides - Optional selector overrides.
*/
function mockBlockEditorStore( overrides: MockBlockEditorOverrides = {} ) {
const defaultBlocks = [
{
clientId: 'block-1',
name: 'core/paragraph',
innerBlocks: [],
},
];
const getBlocks =
overrides.getBlocks ??
jest.fn().mockReturnValue( overrides.blocks ?? defaultBlocks );
( select as jest.Mock ).mockReturnValue( {
getSelectionStart:
overrides.getSelectionStart ?? jest.fn().mockReturnValue( {} ),
getSelectionEnd:
overrides.getSelectionEnd ?? jest.fn().mockReturnValue( {} ),
getSelectedBlocksInitialCaretPosition: jest
.fn()
.mockReturnValue( null ),
getBlockIndex: jest.fn().mockReturnValue( 0 ),
getBlockRootClientId: jest.fn().mockReturnValue( '' ),
getBlockName: jest
.fn()
.mockReturnValue( overrides.getBlockName ?? 'core/paragraph' ),
getBlocks,
} );
return { getBlocks };
}
/**
* Helper to create a single Yjs block with optional text content and inner blocks.
* @param clientId
* @param name
* @param options
* @param options.textContent
* @param options.innerBlocks
*/
function createYBlock(
clientId: string,
name: string,
{
textContent,
innerBlocks = [],
}: { textContent?: string; innerBlocks?: Y.Map< any >[] } = {}
): Y.Map< any > {
const block = new Y.Map();
block.set( 'clientId', clientId );
block.set( 'name', name );
const attrs = new Y.Map();
if ( textContent !== undefined ) {
attrs.set( 'content', new Y.Text( textContent ) );
}
block.set( 'attributes', attrs );
const inner = new Y.Array();
if ( innerBlocks.length ) {
inner.push( innerBlocks );
}
block.set( 'innerBlocks', inner );
return block;
}
/**
* Helper function to create a Y.Doc with blocks structure for testing
* @param blocks
*/
function createTestDocWithBlocks( blocks?: Y.Map< any >[] ) {
const ydoc = new Y.Doc();
const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
const yBlocks = new Y.Array();
documentMap.set( 'blocks', yBlocks );
if ( blocks ) {
yBlocks.push( blocks );
} else {
// Default: single block with content
const block = createYBlock( 'block-1', 'core/paragraph', {
textContent: 'Hello world',
} );
yBlocks.push( [ block ] );
}
return ydoc;
}
describe( 'PostEditorAwareness', () => {
let doc: Y.Doc;
let subscribeCallback: ( () => void ) | null = null;
let mockEditEntityRecord: jest.Mock;
beforeEach( () => {
jest.useFakeTimers();
doc = createTestDocWithBlocks();
mockUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
);
jest.spyOn( Date, 'now' ).mockReturnValue( 1704067200000 );
mockBlockEditorStore();
// Mock subscribe to capture the callback
( subscribe as jest.Mock ).mockImplementation( ( callback ) => {
subscribeCallback = callback;
return jest.fn(); // unsubscribe
} );
// Mock dispatch
mockEditEntityRecord = jest.fn();
( dispatch as jest.Mock ).mockReturnValue( {
editEntityRecord: mockEditEntityRecord,
} );
// Mock resolveSelect for getCurrentUser
( resolveSelect as jest.Mock ).mockReturnValue( {
getCurrentUser: jest.fn().mockResolvedValue( createMockUser() ),
} );
} );
afterEach( () => {
jest.useRealTimers();
jest.restoreAllMocks();
subscribeCallback = null;
doc.destroy();
} );
describe( 'construction', () => {
test( 'should create instance with Y.Doc and entity info', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
expect( awareness ).toBeInstanceOf( PostEditorAwareness );
} );
test( 'should have correct clientID from doc', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
expect( awareness.clientID ).toBe( doc.clientID );
} );
} );
describe( 'setUp', () => {
test( 'should be idempotent', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
awareness.setUp();
// Subscribe should only be called once
expect( subscribe ).toHaveBeenCalledTimes( 1 );
} );
test( 'should subscribe to selection changes', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
expect( subscribe ).toHaveBeenCalled();
expect( subscribeCallback ).not.toBeNull();
} );
} );
describe( 'selection change handling', () => {
test( 'should not trigger update when selection has not changed', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
// Trigger subscribe callback with same selection
subscribeCallback?.();
// Should not call editEntityRecord for unchanged selection
expect( mockEditEntityRecord ).not.toHaveBeenCalled();
} );
test( 'should trigger update when selection changes', () => {
const mockGetSelectionStart = jest
.fn()
.mockReturnValueOnce( {} )
.mockReturnValueOnce( {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
} );
const mockGetSelectionEnd = jest
.fn()
.mockReturnValueOnce( {} )
.mockReturnValueOnce( {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
} );
mockBlockEditorStore( {
getSelectionStart: mockGetSelectionStart,
getSelectionEnd: mockGetSelectionEnd,
} );
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
// Trigger subscribe callback with new selection
subscribeCallback?.();
// Should call editEntityRecord
expect( mockEditEntityRecord ).toHaveBeenCalledWith(
'postType',
'post',
123,
expect.objectContaining( {
selection: expect.any( Object ),
} ),
expect.objectContaining( {
undoIgnore: true,
} )
);
} );
test( 'should debounce local cursor updates', () => {
const mockGetSelectionStart = jest
.fn()
.mockReturnValueOnce( {} )
.mockReturnValueOnce( {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
} );
const mockGetSelectionEnd = jest
.fn()
.mockReturnValueOnce( {} )
.mockReturnValueOnce( {
clientId: 'block-1',
attributeKey: 'content',
offset: 5,
} );
mockBlockEditorStore( {
getSelectionStart: mockGetSelectionStart,
getSelectionEnd: mockGetSelectionEnd,
} );
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
// Trigger selection change
subscribeCallback?.();
// Advance timers past debounce
jest.advanceTimersByTime( 10 );
// Should have processed the debounced update
expect( mockEditEntityRecord ).toHaveBeenCalled();
} );
} );
describe( 'areEditorStatesEqual', () => {
test( 'should return true when both states are undefined', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
// Access the protected method via testing
// We can test this indirectly through setLocalStateField behavior
awareness.setUp();
// Set editorState with a selection
const selectionState: SelectionNone = {
type: SelectionType.None,
};
awareness.setLocalStateField( 'editorState', {
selection: selectionState,
} );
// Subscribe to track updates
const callback = jest.fn();
awareness.onStateChange( callback );
// Emit change event
awareness.emit( 'change', [
{
added: [],
updated: [ awareness.clientID ],
removed: [],
},
] );
callback.mockClear();
// Set same state again - should not trigger unnecessary updates
awareness.setLocalStateField( 'editorState', {
selection: selectionState,
} );
// Emit change event again
awareness.emit( 'change', [
{
added: [],
updated: [ awareness.clientID ],
removed: [],
},
] );
// Callback should not be called for equal editor states
expect( callback ).not.toHaveBeenCalled();
} );
} );
describe( 'convertSelectionStateToAbsolute', () => {
test( 'should return nulls when relative position cannot be resolved', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
// Create a Y.Doc for creating a relative position, then destroy it
// This creates a relative position that cannot be resolved in the awareness doc
const tempDoc = new Y.Doc();
const tempText = tempDoc.getText( 'temp' );
tempText.insert( 0, 'Hello' );
const relativePosition = Y.createRelativePositionFromTypeIndex(
tempText,
2
);
tempDoc.destroy();
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 2,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
// Should return nulls when the relative position's type cannot be found
expect( result.textIndex ).toBeNull();
expect( result.localClientId ).toBeNull();
} );
test( 'should return text index and block client ID for valid cursor selection', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
// Get the Y.Text from the doc
const documentMap = doc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const block = blocks.get( 0 );
const attrs = block.get( 'attributes' ) as Y.Map< Y.Text >;
const yText = attrs.get( 'content' );
// Create a relative position
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText as Y.Text,
5
);
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 5,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBe( 5 );
expect( result.localClientId ).toBe( 'block-1' );
} );
test( 'should resolve WholeBlock selection to block client ID', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
// Get the blocks array from the doc
const documentMap = doc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
// Create a block relative position
const blockPosition = Y.createRelativePositionFromTypeIndex(
blocks,
0
);
const selection: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition,
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBeNull();
expect( result.localClientId ).toBe( 'block-1' );
} );
} );
describe( 'getDebugData', () => {
test( 'should return debug data object', async () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
// Wait for async setup
await Promise.resolve();
// Emit a change to populate seenStates
awareness.emit( 'change', [
{
added: [],
updated: [ awareness.clientID ],
removed: [],
},
] );
const debugData = awareness.getDebugData();
expect( debugData ).toHaveProperty( 'doc' );
expect( debugData ).toHaveProperty( 'clients' );
expect( debugData ).toHaveProperty( 'collaboratorMap' );
} );
test( 'should include document data', async () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
await Promise.resolve();
const debugData = awareness.getDebugData();
expect( debugData.doc ).toHaveProperty( CRDT_RECORD_MAP_KEY );
} );
test( 'should include client items', async () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
await Promise.resolve();
const debugData = awareness.getDebugData();
expect( debugData.clients ).toBeDefined();
expect( typeof debugData.clients ).toBe( 'object' );
} );
} );
describe( 'equalityFieldChecks', () => {
test( 'should include collaboratorInfo check from baseEqualityFieldChecks', async () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
await Promise.resolve();
// Set collaboratorInfo and verify equality check works
const collaboratorInfo: CollaboratorInfo = {
id: 1,
name: 'Test',
slug: 'test',
avatar_urls: mockAvatarUrls,
browserType: 'Chrome',
enteredAt: 1704067200000,
};
awareness.setLocalStateField(
'collaboratorInfo',
collaboratorInfo
);
const storedInfo =
awareness.getLocalStateField( 'collaboratorInfo' );
expect( storedInfo ).toEqual( collaboratorInfo );
} );
test( 'should include editorState check', async () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
awareness.setUp();
await Promise.resolve();
const editorState = {
selection: {
type: SelectionType.None,
} as SelectionNone,
};
awareness.setLocalStateField( 'editorState', editorState );
const storedState = awareness.getLocalStateField( 'editorState' );
expect( storedState ).toEqual( editorState );
} );
} );
describe( 'state subscription', () => {
test( 'should notify subscribers on editorState change', async () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
'post',
123
);
const callback = jest.fn();
awareness.onStateChange( callback );
awareness.setUp();
await Promise.resolve();
// Set initial state to trigger callback
awareness.setLocalStateField( 'editorState', {
selection: { type: SelectionType.None },
} );
// Emit change event
awareness.emit( 'change', [
{
added: [],
updated: [ awareness.clientID ],
removed: [],
},
] );
expect( callback ).toHaveBeenCalled();
} );
} );
describe( 'convertSelectionStateToAbsolute with nested blocks', () => {
test( 'should resolve cursor in second root block (path [1])', () => {
const nestedDoc = createTestDocWithBlocks( [
createYBlock( 'yjs-block-0', 'core/paragraph', {
textContent: 'First',
} ),
createYBlock( 'yjs-block-1', 'core/paragraph', {
textContent: 'Second',
} ),
createYBlock( 'yjs-block-2', 'core/paragraph', {
textContent: 'Third',
} ),
] );
mockBlockEditorStore( {
blocks: [
{ clientId: 'local-0', innerBlocks: [] },
{ clientId: 'local-1', innerBlocks: [] },
{ clientId: 'local-2', innerBlocks: [] },
],
} );
const awareness = new PostEditorAwareness(
nestedDoc,
'postType',
'post',
123
);
// Create a cursor in the third block's text
const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const block2 = blocks.get( 2 );
const attrs2 = block2.get( 'attributes' ) as Y.Map< Y.Text >;
const yText2 = attrs2.get( 'content' ) as Y.Text;
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText2,
2
);
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 2,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBe( 2 );
expect( result.localClientId ).toBe( 'local-2' );
nestedDoc.destroy();
} );
test( 'should resolve cursor in a nested inner block (path [0, 1])', () => {
const innerParagraph0 = createYBlock(
'yjs-inner-0',
'core/paragraph',
{ textContent: 'Inner zero' }
);
const innerParagraph1 = createYBlock(
'yjs-inner-1',
'core/paragraph',
{ textContent: 'Inner one' }
);
const outerColumn = createYBlock( 'yjs-outer', 'core/column', {
innerBlocks: [ innerParagraph0, innerParagraph1 ],
} );
const nestedDoc = createTestDocWithBlocks( [ outerColumn ] );
mockBlockEditorStore( {
blocks: [
{
clientId: 'local-outer',
innerBlocks: [
{ clientId: 'local-inner-0', innerBlocks: [] },
{ clientId: 'local-inner-1', innerBlocks: [] },
],
},
],
} );
const awareness = new PostEditorAwareness(
nestedDoc,
'postType',
'post',
123
);
// Create cursor in the second inner paragraph
const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const outer = blocks.get( 0 );
const innerBlocks = outer.get( 'innerBlocks' ) as Y.Array<
Y.Map< any >
>;
const innerBlock1 = innerBlocks.get( 1 );
const innerAttrs = innerBlock1.get(
'attributes'
) as Y.Map< Y.Text >;
const yText = innerAttrs.get( 'content' ) as Y.Text;
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
5
);
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 5,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBe( 5 );
expect( result.localClientId ).toBe( 'local-inner-1' );
nestedDoc.destroy();
} );
test( 'should resolve WholeBlock for a nested image block', () => {
const innerImage = createYBlock( 'yjs-img', 'core/image' );
const outerColumn = createYBlock( 'yjs-col', 'core/column', {
innerBlocks: [ innerImage ],
} );
const nestedDoc = createTestDocWithBlocks( [ outerColumn ] );
mockBlockEditorStore( {
blocks: [
{
clientId: 'local-col',
innerBlocks: [
{ clientId: 'local-img', innerBlocks: [] },
],
},
],
} );
const awareness = new PostEditorAwareness(
nestedDoc,
'postType',
'post',
123
);
// Create a WholeBlock relative position for the inner image
const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const outer = blocks.get( 0 );
const innerBlocks = outer.get( 'innerBlocks' ) as Y.Array<
Y.Map< any >
>;
const blockPosition = Y.createRelativePositionFromTypeIndex(
innerBlocks,
0
);
const selection: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition,
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBeNull();
expect( result.localClientId ).toBe( 'local-img' );
nestedDoc.destroy();
} );
test( 'should resolve a deeply nested block (path [1, 0, 1])', () => {
const deepParagraph0 = createYBlock(
'yjs-deep-0',
'core/paragraph',
{ textContent: 'Deep zero' }
);
const deepParagraph1 = createYBlock(
'yjs-deep-1',
'core/paragraph',
{ textContent: 'Deep one content' }
);
const midColumn = createYBlock( 'yjs-mid', 'core/column', {
innerBlocks: [ deepParagraph0, deepParagraph1 ],
} );
const outerColumns0 = createYBlock( 'yjs-outer-0', 'core/columns' );
const outerColumns1 = createYBlock( 'yjs-outer-1', 'core/columns', {
innerBlocks: [ midColumn ],
} );
const nestedDoc = createTestDocWithBlocks( [
outerColumns0,
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 awareness = new PostEditorAwareness(
nestedDoc,
'postType',
'post',
123
);
// Create cursor in the deeply nested second paragraph
const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const outer1 = blocks.get( 1 );
const outer1Inner = outer1.get( 'innerBlocks' ) as Y.Array<
Y.Map< any >
>;
const mid = outer1Inner.get( 0 );
const midInner = mid.get( 'innerBlocks' ) as Y.Array<
Y.Map< any >
>;
const deep1 = midInner.get( 1 );
const deep1Attrs = deep1.get( 'attributes' ) as Y.Map< Y.Text >;
const yText = deep1Attrs.get( 'content' ) as Y.Text;
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
7
);
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 7,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBe( 7 );
expect( result.localClientId ).toBe( 'local-deep-1' );
nestedDoc.destroy();
} );
} );
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)
const templateDoc = createTestDocWithBlocks( [
createYBlock( 'yjs-para-0', 'core/paragraph', {
textContent: 'Post paragraph 1',
} ),
createYBlock( 'yjs-para-1', 'core/paragraph', {
textContent: 'Post paragraph 2',
} ),
] );
// 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 } );
const awareness = new PostEditorAwareness(
templateDoc,
'postType',
'post',
123
);
// Create cursor in the second post content paragraph
const documentMap = templateDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const block1 = blocks.get( 1 );
const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >;
const yText = attrs.get( 'content' ) as Y.Text;
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
4
);
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 4,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).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', () => {
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 awareness = new PostEditorAwareness(
templateDoc,
'postType',
'post',
123
);
const documentMap = templateDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const blockPosition = Y.createRelativePositionFromTypeIndex(
blocks,
0
);
const selection: SelectionWholeBlock = {
type: SelectionType.WholeBlock,
blockPosition,
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBeNull();
expect( result.localClientId ).toBe( 'local-img' );
templateDoc.destroy();
} );
test( 'should fall back to root blocks when no core/post-content exists', () => {
// Normal mode (no template) — should use root blocks directly
const normalDoc = createTestDocWithBlocks( [
createYBlock( 'yjs-para', 'core/paragraph', {
textContent: 'Normal mode',
} ),
] );
mockBlockEditorStore( {
blocks: [ { clientId: 'local-para', innerBlocks: [] } ],
} );
const awareness = new PostEditorAwareness(
normalDoc,
'postType',
'post',
123
);
const documentMap = normalDoc.getMap( CRDT_RECORD_MAP_KEY );
const blocks = documentMap.get( 'blocks' ) as Y.Array<
Y.Map< any >
>;
const block = blocks.get( 0 );
const attrs = block.get( 'attributes' ) as Y.Map< Y.Text >;
const yText = attrs.get( 'content' ) as Y.Text;
const relativePosition = Y.createRelativePositionFromTypeIndex(
yText,
3
);
const selection: SelectionCursor = {
type: SelectionType.Cursor,
cursorPosition: {
relativePosition,
absoluteOffset: 3,
},
};
const result =
awareness.convertSelectionStateToAbsolute( selection );
expect( result.textIndex ).toBe( 3 );
expect( result.localClientId ).toBe( 'local-para' );
normalDoc.destroy();
} );
} );
} );