@wordpress/core-data 7.40.1 → 7.40.2-next.v.202602271551.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/build/actions.cjs +23 -29
  2. package/build/actions.cjs.map +2 -2
  3. package/build/awareness/block-lookup.cjs +103 -0
  4. package/build/awareness/block-lookup.cjs.map +7 -0
  5. package/build/awareness/post-editor-awareness.cjs +45 -7
  6. package/build/awareness/post-editor-awareness.cjs.map +3 -3
  7. package/build/entities.cjs +63 -59
  8. package/build/entities.cjs.map +2 -2
  9. package/build/entity-types/icon.cjs +19 -0
  10. package/build/entity-types/icon.cjs.map +7 -0
  11. package/build/entity-types/index.cjs.map +1 -1
  12. package/build/hooks/use-post-editor-awareness-state.cjs +12 -8
  13. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  14. package/build/private-actions.cjs +0 -8
  15. package/build/private-actions.cjs.map +2 -2
  16. package/build/private-apis.cjs +1 -1
  17. package/build/private-apis.cjs.map +1 -1
  18. package/build/private-selectors.cjs +1 -9
  19. package/build/private-selectors.cjs.map +2 -2
  20. package/build/reducer.cjs +0 -10
  21. package/build/reducer.cjs.map +2 -2
  22. package/build/resolvers.cjs +116 -125
  23. package/build/resolvers.cjs.map +2 -2
  24. package/build/selectors.cjs.map +2 -2
  25. package/build/sync.cjs +1 -7
  26. package/build/sync.cjs.map +2 -2
  27. package/build/types.cjs.map +1 -1
  28. package/build/utils/crdt-blocks.cjs +50 -31
  29. package/build/utils/crdt-blocks.cjs.map +2 -2
  30. package/build/utils/crdt-selection.cjs +47 -19
  31. package/build/utils/crdt-selection.cjs.map +2 -2
  32. package/build/utils/crdt-user-selections.cjs +78 -22
  33. package/build/utils/crdt-user-selections.cjs.map +3 -3
  34. package/build/utils/crdt.cjs +12 -1
  35. package/build/utils/crdt.cjs.map +2 -2
  36. package/build-module/actions.mjs +23 -29
  37. package/build-module/actions.mjs.map +2 -2
  38. package/build-module/awareness/block-lookup.mjs +77 -0
  39. package/build-module/awareness/block-lookup.mjs.map +7 -0
  40. package/build-module/awareness/post-editor-awareness.mjs +47 -8
  41. package/build-module/awareness/post-editor-awareness.mjs.map +3 -3
  42. package/build-module/entities.mjs +65 -60
  43. package/build-module/entities.mjs.map +2 -2
  44. package/build-module/entity-types/icon.mjs +1 -0
  45. package/build-module/entity-types/icon.mjs.map +7 -0
  46. package/build-module/hooks/use-post-editor-awareness-state.mjs +10 -6
  47. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  48. package/build-module/private-actions.mjs +0 -7
  49. package/build-module/private-actions.mjs.map +2 -2
  50. package/build-module/private-apis.mjs +2 -2
  51. package/build-module/private-apis.mjs.map +1 -1
  52. package/build-module/private-selectors.mjs +2 -12
  53. package/build-module/private-selectors.mjs.map +2 -2
  54. package/build-module/reducer.mjs +0 -9
  55. package/build-module/reducer.mjs.map +2 -2
  56. package/build-module/resolvers.mjs +116 -124
  57. package/build-module/resolvers.mjs.map +2 -2
  58. package/build-module/selectors.mjs.map +2 -2
  59. package/build-module/sync.mjs +1 -5
  60. package/build-module/sync.mjs.map +2 -2
  61. package/build-module/utils/crdt-blocks.mjs +50 -31
  62. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  63. package/build-module/utils/crdt-selection.mjs +46 -19
  64. package/build-module/utils/crdt-selection.mjs.map +2 -2
  65. package/build-module/utils/crdt-user-selections.mjs +77 -22
  66. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  67. package/build-module/utils/crdt.mjs +16 -6
  68. package/build-module/utils/crdt.mjs.map +2 -2
  69. package/build-types/actions.d.ts.map +1 -1
  70. package/build-types/awareness/block-lookup.d.ts +29 -0
  71. package/build-types/awareness/block-lookup.d.ts.map +1 -0
  72. package/build-types/awareness/post-editor-awareness.d.ts +18 -5
  73. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  74. package/build-types/awareness/test/block-lookup.d.ts +2 -0
  75. package/build-types/awareness/test/block-lookup.d.ts.map +1 -0
  76. package/build-types/entities.d.ts +16 -0
  77. package/build-types/entities.d.ts.map +1 -1
  78. package/build-types/entity-types/icon.d.ts +25 -0
  79. package/build-types/entity-types/icon.d.ts.map +1 -0
  80. package/build-types/entity-types/index.d.ts +3 -2
  81. package/build-types/entity-types/index.d.ts.map +1 -1
  82. package/build-types/hooks/use-post-editor-awareness-state.d.ts +11 -6
  83. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  84. package/build-types/index.d.ts.map +1 -1
  85. package/build-types/private-actions.d.ts +0 -8
  86. package/build-types/private-actions.d.ts.map +1 -1
  87. package/build-types/private-selectors.d.ts +1 -8
  88. package/build-types/private-selectors.d.ts.map +1 -1
  89. package/build-types/reducer.d.ts +0 -11
  90. package/build-types/reducer.d.ts.map +1 -1
  91. package/build-types/resolvers.d.ts +0 -3
  92. package/build-types/resolvers.d.ts.map +1 -1
  93. package/build-types/selectors.d.ts +0 -6
  94. package/build-types/selectors.d.ts.map +1 -1
  95. package/build-types/sync.d.ts +2 -2
  96. package/build-types/sync.d.ts.map +1 -1
  97. package/build-types/types.d.ts +14 -5
  98. package/build-types/types.d.ts.map +1 -1
  99. package/build-types/utils/crdt-blocks.d.ts +1 -1
  100. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  101. package/build-types/utils/crdt-selection.d.ts +10 -0
  102. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  103. package/build-types/utils/crdt-user-selections.d.ts +21 -4
  104. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  105. package/build-types/utils/crdt.d.ts +1 -0
  106. package/build-types/utils/crdt.d.ts.map +1 -1
  107. package/build-types/utils/test/crdt-user-selections.d.ts +2 -0
  108. package/build-types/utils/test/crdt-user-selections.d.ts.map +1 -0
  109. package/package.json +18 -18
  110. package/src/actions.js +39 -45
  111. package/src/awareness/block-lookup.ts +169 -0
  112. package/src/awareness/post-editor-awareness.ts +68 -11
  113. package/src/awareness/test/block-lookup.ts +504 -0
  114. package/src/awareness/test/post-editor-awareness.ts +662 -38
  115. package/src/entities.js +71 -62
  116. package/src/entity-types/icon.ts +30 -0
  117. package/src/entity-types/index.ts +3 -0
  118. package/src/hooks/test/use-post-editor-awareness-state.ts +21 -14
  119. package/src/hooks/use-post-editor-awareness-state.ts +22 -13
  120. package/src/private-actions.js +0 -14
  121. package/src/private-apis.js +2 -2
  122. package/src/private-selectors.ts +3 -22
  123. package/src/reducer.js +0 -17
  124. package/src/resolvers.js +158 -171
  125. package/src/selectors.ts +0 -7
  126. package/src/sync.ts +0 -4
  127. package/src/test/entities.js +39 -10
  128. package/src/test/resolvers.js +155 -81
  129. package/src/types.ts +23 -5
  130. package/src/utils/crdt-blocks.ts +113 -47
  131. package/src/utils/crdt-selection.ts +87 -25
  132. package/src/utils/crdt-user-selections.ts +129 -47
  133. package/src/utils/crdt.ts +23 -7
  134. package/src/utils/test/crdt-blocks.ts +591 -0
  135. package/src/utils/test/crdt-user-selections.ts +894 -0
  136. package/src/utils/test/crdt.ts +136 -10
@@ -10,6 +10,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
10
10
  * Internal dependencies
11
11
  */
12
12
  import { BaseAwarenessState, baseEqualityFieldChecks } from './base-awareness';
13
+ import { getBlockPathInYdoc, resolveBlockClientIdByPath } from './block-lookup';
13
14
  import {
14
15
  AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS,
15
16
  LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS,
@@ -18,9 +19,11 @@ import { STORE_NAME as coreStore } from '../name';
18
19
  import {
19
20
  areSelectionsStatesEqual,
20
21
  getSelectionState,
22
+ SelectionType,
21
23
  } from '../utils/crdt-user-selections';
22
24
 
23
- import type { SelectionCursor, WPBlockSelection } from '../types';
25
+ import type { SelectionState, WPBlockSelection } from '../types';
26
+ import type { YBlocks } from '../utils/crdt-blocks';
24
27
  import type {
25
28
  DebugCollaboratorData,
26
29
  EditorState,
@@ -172,20 +175,74 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
172
175
  }
173
176
 
174
177
  /**
175
- * Get the absolute position index from a selection cursor.
178
+ * Resolve a selection state to a text index and block client ID.
176
179
  *
177
- * @param selection - The selection cursor.
178
- * @return The absolute position index, or null if not found.
180
+ * For text-based selections, navigates up from the resolved Y.Text via
181
+ * AbstractType.parent to find the containing block, then resolves the
182
+ * local clientId via the block's tree path.
183
+ * For WholeBlock selections, resolves the block's relative position and
184
+ * then finds the local clientId via tree path.
185
+ *
186
+ * Tree-path resolution is used instead of reading the clientId directly
187
+ * from the Yjs block because the local block-editor store may use different
188
+ * clientIds (e.g. in "Show Template" mode where blocks are cloned).
189
+ *
190
+ * @param selection - The selection state.
191
+ * @return The text index and block client ID, or nulls if not resolvable.
179
192
  */
180
- public getAbsolutePositionIndex(
181
- selection: SelectionCursor
182
- ): number | null {
183
- return (
184
- Y.createAbsolutePositionFromRelativePosition(
185
- selection.cursorPosition.relativePosition,
193
+ public convertSelectionStateToAbsolute( selection: SelectionState ): {
194
+ textIndex: number | null;
195
+ localClientId: string | null;
196
+ } {
197
+ if ( selection.type === SelectionType.None ) {
198
+ return { textIndex: null, localClientId: null };
199
+ }
200
+
201
+ if ( selection.type === SelectionType.WholeBlock ) {
202
+ const absolutePos = Y.createAbsolutePositionFromRelativePosition(
203
+ selection.blockPosition,
186
204
  this.doc
187
- )?.index ?? null
205
+ );
206
+
207
+ let localClientId: string | null = null;
208
+
209
+ if ( absolutePos && absolutePos.type instanceof Y.Array ) {
210
+ const parentArray = absolutePos.type as YBlocks;
211
+ const block = parentArray.get( absolutePos.index );
212
+
213
+ if ( block instanceof Y.Map ) {
214
+ const path = getBlockPathInYdoc( block );
215
+ localClientId = path
216
+ ? resolveBlockClientIdByPath( path )
217
+ : null;
218
+ }
219
+ }
220
+
221
+ return { textIndex: null, localClientId };
222
+ }
223
+
224
+ // Text-based selections: resolve cursor position and navigate up.
225
+ const cursorPos =
226
+ 'cursorPosition' in selection
227
+ ? selection.cursorPosition
228
+ : selection.cursorStartPosition;
229
+
230
+ const absolutePosition = Y.createAbsolutePositionFromRelativePosition(
231
+ cursorPos.relativePosition,
232
+ this.doc
188
233
  );
234
+
235
+ if ( ! absolutePosition ) {
236
+ return { textIndex: null, localClientId: null };
237
+ }
238
+
239
+ // Navigate up: Y.Text -> attributes Y.Map -> block Y.Map
240
+ const yType = absolutePosition.type.parent?.parent;
241
+ const path =
242
+ yType instanceof Y.Map ? getBlockPathInYdoc( yType ) : null;
243
+ const localClientId = path ? resolveBlockClientIdByPath( path ) : null;
244
+
245
+ return { textIndex: absolutePosition.index, localClientId };
189
246
  }
190
247
 
191
248
  /**
@@ -0,0 +1,504 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { Y } from '@wordpress/sync';
5
+ import { select } from '@wordpress/data';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import {
11
+ getBlockPathInYdoc,
12
+ resolveBlockClientIdByPath,
13
+ } from '../block-lookup';
14
+
15
+ // Mock WordPress dependencies
16
+ jest.mock( '@wordpress/data', () => ( {
17
+ select: jest.fn(),
18
+ } ) );
19
+
20
+ jest.mock( '@wordpress/block-editor', () => ( {
21
+ store: 'core/block-editor',
22
+ } ) );
23
+
24
+ type MockBlock = {
25
+ clientId: string;
26
+ name: string;
27
+ innerBlocks: MockBlock[];
28
+ };
29
+
30
+ /**
31
+ * Create a Y.Map block with a clientId and empty innerBlocks, matching the
32
+ * shape used by the Yjs block tree.
33
+ *
34
+ * @param clientId Block client ID.
35
+ */
36
+ function createTestYBlock( clientId: string ): Y.Map< any > {
37
+ const block = new Y.Map< any >();
38
+ block.set( 'clientId', clientId );
39
+ block.set( 'innerBlocks', new Y.Array< Y.Map< any > >() );
40
+ return block;
41
+ }
42
+
43
+ /**
44
+ * Create a Y.Map block with a clientId and the given innerBlocks array,
45
+ * for blocks that need pre-populated children.
46
+ *
47
+ * @param clientId Block client ID.
48
+ * @param innerBlocks A Y.Array to use as the block's innerBlocks.
49
+ */
50
+ function createTestYBlockWithInner(
51
+ clientId: string,
52
+ innerBlocks: Y.Array< Y.Map< any > >
53
+ ): Y.Map< any > {
54
+ const block = new Y.Map< any >();
55
+ block.set( 'clientId', clientId );
56
+ block.set( 'innerBlocks', innerBlocks );
57
+ return block;
58
+ }
59
+
60
+ /**
61
+ * Helper to create a Y.Doc with a flat list of blocks, each having a
62
+ * `clientId` and an empty `innerBlocks` Y.Array.
63
+ *
64
+ * @param count Number of root blocks to create.
65
+ * @return The Y.Doc and the root blocks Y.Array.
66
+ */
67
+ function createFlatYDoc( count: number ) {
68
+ const ydoc = new Y.Doc();
69
+ const rootMap = ydoc.getMap( 'test' );
70
+ const blocks = new Y.Array< Y.Map< any > >();
71
+ rootMap.set( 'blocks', blocks );
72
+
73
+ const yBlocks: Y.Map< any >[] = [];
74
+ for ( let i = 0; i < count; i++ ) {
75
+ yBlocks.push( createTestYBlock( `block-${ i }` ) );
76
+ }
77
+ blocks.push( yBlocks );
78
+
79
+ return { ydoc, blocks };
80
+ }
81
+
82
+ /**
83
+ * Helper to create a Y.Doc with nested blocks.
84
+ *
85
+ * Creates `rootCount` root blocks, then adds `innerCount` inner blocks
86
+ * to the block at index `parentIndex`.
87
+ * @param root0
88
+ * @param root0.rootCount
89
+ * @param root0.parentIndex
90
+ * @param root0.innerCount
91
+ */
92
+ function createNestedYDoc( {
93
+ rootCount,
94
+ parentIndex,
95
+ innerCount,
96
+ }: {
97
+ rootCount: number;
98
+ parentIndex: number;
99
+ innerCount: number;
100
+ } ) {
101
+ const ydoc = new Y.Doc();
102
+ const rootMap = ydoc.getMap( 'test' );
103
+ const rootBlocks = new Y.Array< Y.Map< any > >();
104
+ rootMap.set( 'blocks', rootBlocks );
105
+
106
+ const yRootBlocks: Y.Map< any >[] = [];
107
+ for ( let i = 0; i < rootCount; i++ ) {
108
+ yRootBlocks.push( createTestYBlock( `root-${ i }` ) );
109
+ }
110
+ rootBlocks.push( yRootBlocks );
111
+
112
+ // Add inner blocks to the specified parent.
113
+ const parentBlock = rootBlocks.get( parentIndex );
114
+ const innerBlocksArray = parentBlock.get( 'innerBlocks' ) as Y.Array<
115
+ Y.Map< any >
116
+ >;
117
+
118
+ const yInnerBlocks: Y.Map< any >[] = [];
119
+ for ( let j = 0; j < innerCount; j++ ) {
120
+ yInnerBlocks.push(
121
+ createTestYBlock( `inner-${ parentIndex }-${ j }` )
122
+ );
123
+ }
124
+ innerBlocksArray.push( yInnerBlocks );
125
+
126
+ return { ydoc, rootBlocks, innerBlocksArray };
127
+ }
128
+
129
+ /**
130
+ * Mock the block-editor store's `getBlocks` selector.
131
+ *
132
+ * When called without an argument (or undefined), returns `rootBlocks`.
133
+ * When called with a clientId, looks up the block by clientId and returns
134
+ * its innerBlocks. This mimics how `getBlocks( clientId )` works in the
135
+ * real store for controlled inner blocks.
136
+ * @param rootBlocks
137
+ */
138
+ function mockBlockEditorStore( rootBlocks: MockBlock[] ) {
139
+ const allBlocks = new Map< string, MockBlock >();
140
+
141
+ function indexBlocks( blocks: MockBlock[] ) {
142
+ for ( const block of blocks ) {
143
+ allBlocks.set( block.clientId, block );
144
+ if ( block.innerBlocks?.length ) {
145
+ indexBlocks( block.innerBlocks );
146
+ }
147
+ }
148
+ }
149
+ indexBlocks( rootBlocks );
150
+
151
+ const getBlocks = jest.fn( ( rootClientId?: string ) => {
152
+ if ( rootClientId === undefined ) {
153
+ return rootBlocks;
154
+ }
155
+ const block = allBlocks.get( rootClientId );
156
+ return block ? block.innerBlocks : [];
157
+ } );
158
+
159
+ ( select as jest.Mock ).mockReturnValue( { getBlocks } );
160
+ return { getBlocks };
161
+ }
162
+
163
+ describe( 'getBlockPathInYdoc', () => {
164
+ it( 'should return path [0] for the first root block', () => {
165
+ const { blocks } = createFlatYDoc( 3 );
166
+ const firstBlock = blocks.get( 0 );
167
+
168
+ expect( getBlockPathInYdoc( firstBlock ) ).toEqual( [ 0 ] );
169
+ } );
170
+
171
+ it( 'should return path [2] for the third root block', () => {
172
+ const { blocks } = createFlatYDoc( 3 );
173
+ const thirdBlock = blocks.get( 2 );
174
+
175
+ expect( getBlockPathInYdoc( thirdBlock ) ).toEqual( [ 2 ] );
176
+ } );
177
+
178
+ it( 'should return a nested path for an inner block', () => {
179
+ const { innerBlocksArray } = createNestedYDoc( {
180
+ rootCount: 2,
181
+ parentIndex: 1,
182
+ innerCount: 3,
183
+ } );
184
+
185
+ // Second inner block of the second root block → [1, 1]
186
+ const innerBlock = innerBlocksArray.get( 1 );
187
+ expect( getBlockPathInYdoc( innerBlock ) ).toEqual( [ 1, 1 ] );
188
+ } );
189
+
190
+ it( 'should return [parentIndex, 0] for the first inner block', () => {
191
+ const { innerBlocksArray } = createNestedYDoc( {
192
+ rootCount: 3,
193
+ parentIndex: 0,
194
+ innerCount: 2,
195
+ } );
196
+
197
+ const firstInner = innerBlocksArray.get( 0 );
198
+ expect( getBlockPathInYdoc( firstInner ) ).toEqual( [ 0, 0 ] );
199
+ } );
200
+
201
+ it( 'should return null for a Y.Map without a parent array', () => {
202
+ const orphan = new Y.Map< any >();
203
+ orphan.set( 'clientId', 'orphan' );
204
+
205
+ expect( getBlockPathInYdoc( orphan ) ).toBeNull();
206
+ } );
207
+
208
+ it( 'should handle deeply nested blocks', () => {
209
+ // Build a 3-level deep structure so the target block is at [2, 7, 1].
210
+ const ydoc = new Y.Doc();
211
+ const rootMap = ydoc.getMap( 'test' );
212
+ const rootBlocks = new Y.Array< Y.Map< any > >();
213
+ rootMap.set( 'blocks', rootBlocks );
214
+
215
+ // 3 root blocks — the target parent is at index 2.
216
+ const innerArray = new Y.Array< Y.Map< any > >();
217
+ rootBlocks.push( [
218
+ createTestYBlock( 'root-0' ),
219
+ createTestYBlock( 'root-1' ),
220
+ createTestYBlockWithInner( 'root-2', innerArray ),
221
+ ] );
222
+
223
+ // 8 inner blocks inside root-2 — the target parent is at index 7.
224
+ const grandchildArray = new Y.Array< Y.Map< any > >();
225
+ const fillerInners: Y.Map< any >[] = [];
226
+ for ( let i = 0; i < 7; i++ ) {
227
+ fillerInners.push( createTestYBlock( `inner-${ i }` ) );
228
+ }
229
+ innerArray.push( [
230
+ ...fillerInners,
231
+ createTestYBlockWithInner( 'inner-7', grandchildArray ),
232
+ ] );
233
+
234
+ // 2 grandchildren inside inner-7 — the target is at index 1.
235
+ const grandchild = createTestYBlock( 'target' );
236
+ grandchildArray.push( [
237
+ createTestYBlock( 'grandchild-0' ),
238
+ grandchild,
239
+ ] );
240
+
241
+ expect( getBlockPathInYdoc( grandchild ) ).toEqual( [ 2, 7, 1 ] );
242
+ } );
243
+ } );
244
+
245
+ describe( 'resolveBlockClientIdByPath', () => {
246
+ afterEach( () => {
247
+ jest.restoreAllMocks();
248
+ } );
249
+
250
+ it( 'should return null for an empty path', () => {
251
+ mockBlockEditorStore( [] );
252
+ expect( resolveBlockClientIdByPath( [] ) ).toBeNull();
253
+ } );
254
+
255
+ it( 'should resolve a root block by single-element path', () => {
256
+ mockBlockEditorStore( [
257
+ { clientId: 'a', name: 'core/paragraph', innerBlocks: [] },
258
+ { clientId: 'b', name: 'core/heading', innerBlocks: [] },
259
+ ] );
260
+
261
+ expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'a' );
262
+ expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBe( 'b' );
263
+ } );
264
+
265
+ it( 'should return null for an out-of-bounds index', () => {
266
+ mockBlockEditorStore( [
267
+ { clientId: 'a', name: 'core/paragraph', innerBlocks: [] },
268
+ ] );
269
+
270
+ expect( resolveBlockClientIdByPath( [ 5 ] ) ).toBeNull();
271
+ } );
272
+
273
+ it( 'should resolve a nested inner block', () => {
274
+ mockBlockEditorStore( [
275
+ {
276
+ clientId: 'parent',
277
+ name: 'core/group',
278
+ innerBlocks: [
279
+ {
280
+ clientId: 'child-0',
281
+ name: 'core/paragraph',
282
+ innerBlocks: [],
283
+ },
284
+ {
285
+ clientId: 'child-1',
286
+ name: 'core/heading',
287
+ innerBlocks: [],
288
+ },
289
+ ],
290
+ },
291
+ ] );
292
+
293
+ expect( resolveBlockClientIdByPath( [ 0, 1 ] ) ).toBe( 'child-1' );
294
+ } );
295
+
296
+ it( 'should return null when inner path index is out of bounds', () => {
297
+ mockBlockEditorStore( [
298
+ {
299
+ clientId: 'parent',
300
+ name: 'core/group',
301
+ innerBlocks: [
302
+ {
303
+ clientId: 'child-0',
304
+ name: 'core/paragraph',
305
+ innerBlocks: [],
306
+ },
307
+ ],
308
+ },
309
+ ] );
310
+
311
+ expect( resolveBlockClientIdByPath( [ 0, 5 ] ) ).toBeNull();
312
+ } );
313
+
314
+ describe( 'template mode (getPostContentBlocks behavior)', () => {
315
+ it( 'should navigate through core/post-content in template mode', () => {
316
+ const postContentInnerBlocks: MockBlock[] = [
317
+ {
318
+ clientId: 'post-para-0',
319
+ name: 'core/paragraph',
320
+ innerBlocks: [],
321
+ },
322
+ {
323
+ clientId: 'post-para-1',
324
+ name: 'core/heading',
325
+ innerBlocks: [],
326
+ },
327
+ ];
328
+
329
+ // Template structure: header → post-content → footer.
330
+ // post-content's innerBlocks are empty in the tree (controlled
331
+ // inner blocks), so getBlocks( postContentClientId ) is used.
332
+ const templateBlocks: MockBlock[] = [
333
+ {
334
+ clientId: 'header',
335
+ name: 'core/template-part',
336
+ innerBlocks: [],
337
+ },
338
+ {
339
+ clientId: 'post-content',
340
+ name: 'core/post-content',
341
+ innerBlocks: [], // Empty — controlled inner blocks.
342
+ },
343
+ {
344
+ clientId: 'footer',
345
+ name: 'core/template-part',
346
+ innerBlocks: [],
347
+ },
348
+ ];
349
+
350
+ const { getBlocks } = mockBlockEditorStore( templateBlocks );
351
+
352
+ // Override getBlocks to return post content blocks when called
353
+ // with the post-content clientId (mimicking controlled inner
354
+ // blocks behavior in useBlockSync).
355
+ getBlocks.mockImplementation( ( rootClientId?: string ) => {
356
+ if ( rootClientId === undefined ) {
357
+ return templateBlocks;
358
+ }
359
+ if ( rootClientId === 'post-content' ) {
360
+ return postContentInnerBlocks;
361
+ }
362
+ return [];
363
+ } );
364
+
365
+ // Path [0] should resolve to the first post content block,
366
+ // not the first template block.
367
+ expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'post-para-0' );
368
+ expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBe( 'post-para-1' );
369
+ } );
370
+
371
+ it( 'should call getBlocks with post-content clientId', () => {
372
+ const templateBlocks: MockBlock[] = [
373
+ {
374
+ clientId: 'header',
375
+ name: 'core/template-part',
376
+ innerBlocks: [],
377
+ },
378
+ {
379
+ clientId: 'pc',
380
+ name: 'core/post-content',
381
+ innerBlocks: [],
382
+ },
383
+ ];
384
+
385
+ const { getBlocks } = mockBlockEditorStore( templateBlocks );
386
+ getBlocks.mockImplementation( ( rootClientId?: string ) => {
387
+ if ( rootClientId === undefined ) {
388
+ return templateBlocks;
389
+ }
390
+ if ( rootClientId === 'pc' ) {
391
+ return [
392
+ {
393
+ clientId: 'inner',
394
+ name: 'core/paragraph',
395
+ innerBlocks: [],
396
+ },
397
+ ];
398
+ }
399
+ return [];
400
+ } );
401
+
402
+ resolveBlockClientIdByPath( [ 0 ] );
403
+
404
+ // Verify getBlocks was called with the post-content clientId.
405
+ expect( getBlocks ).toHaveBeenCalledWith( 'pc' );
406
+ } );
407
+
408
+ it( 'should find core/post-content nested inside template parts', () => {
409
+ // post-content can be nested inside other blocks in the
410
+ // template tree (e.g. inside a group or template part).
411
+ const postContentInnerBlocks: MockBlock[] = [
412
+ {
413
+ clientId: 'deep-para',
414
+ name: 'core/paragraph',
415
+ innerBlocks: [],
416
+ },
417
+ ];
418
+
419
+ const templateBlocks: MockBlock[] = [
420
+ {
421
+ clientId: 'group',
422
+ name: 'core/group',
423
+ innerBlocks: [
424
+ {
425
+ clientId: 'nested-pc',
426
+ name: 'core/post-content',
427
+ innerBlocks: [],
428
+ },
429
+ ],
430
+ },
431
+ ];
432
+
433
+ const { getBlocks } = mockBlockEditorStore( templateBlocks );
434
+ getBlocks.mockImplementation( ( rootClientId?: string ) => {
435
+ if ( rootClientId === undefined ) {
436
+ return templateBlocks;
437
+ }
438
+ if ( rootClientId === 'nested-pc' ) {
439
+ return postContentInnerBlocks;
440
+ }
441
+ return [];
442
+ } );
443
+
444
+ expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'deep-para' );
445
+ } );
446
+
447
+ it( 'should use root blocks directly when no core/post-content exists', () => {
448
+ // No template mode — plain post editing.
449
+ const blocks: MockBlock[] = [
450
+ {
451
+ clientId: 'para-0',
452
+ name: 'core/paragraph',
453
+ innerBlocks: [],
454
+ },
455
+ {
456
+ clientId: 'para-1',
457
+ name: 'core/heading',
458
+ innerBlocks: [],
459
+ },
460
+ ];
461
+
462
+ mockBlockEditorStore( blocks );
463
+
464
+ expect( resolveBlockClientIdByPath( [ 0 ] ) ).toBe( 'para-0' );
465
+ expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBe( 'para-1' );
466
+ } );
467
+
468
+ it( 'should return null for invalid path in template mode', () => {
469
+ const templateBlocks: MockBlock[] = [
470
+ {
471
+ clientId: 'header',
472
+ name: 'core/template-part',
473
+ innerBlocks: [],
474
+ },
475
+ {
476
+ clientId: 'pc',
477
+ name: 'core/post-content',
478
+ innerBlocks: [],
479
+ },
480
+ ];
481
+
482
+ const { getBlocks } = mockBlockEditorStore( templateBlocks );
483
+ getBlocks.mockImplementation( ( rootClientId?: string ) => {
484
+ if ( rootClientId === undefined ) {
485
+ return templateBlocks;
486
+ }
487
+ if ( rootClientId === 'pc' ) {
488
+ // Post content has only one block.
489
+ return [
490
+ {
491
+ clientId: 'only-block',
492
+ name: 'core/paragraph',
493
+ innerBlocks: [],
494
+ },
495
+ ];
496
+ }
497
+ return [];
498
+ } );
499
+
500
+ // Index 1 is out of bounds for the post content blocks.
501
+ expect( resolveBlockClientIdByPath( [ 1 ] ) ).toBeNull();
502
+ } );
503
+ } );
504
+ } );