@wordpress/core-data 7.40.1 → 7.40.2-next.v.202602241322.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 (118) 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 +60 -63
  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 +101 -113
  23. package/build/resolvers.cjs.map +2 -2
  24. package/build/selectors.cjs.map +2 -2
  25. package/build/sync.cjs +0 -3
  26. package/build/sync.cjs.map +2 -2
  27. package/build/types.cjs.map +1 -1
  28. package/build/utils/crdt-selection.cjs +1 -1
  29. package/build/utils/crdt-selection.cjs.map +2 -2
  30. package/build/utils/crdt-user-selections.cjs +78 -22
  31. package/build/utils/crdt-user-selections.cjs.map +3 -3
  32. package/build-module/actions.mjs +23 -29
  33. package/build-module/actions.mjs.map +2 -2
  34. package/build-module/awareness/block-lookup.mjs +77 -0
  35. package/build-module/awareness/block-lookup.mjs.map +7 -0
  36. package/build-module/awareness/post-editor-awareness.mjs +47 -8
  37. package/build-module/awareness/post-editor-awareness.mjs.map +3 -3
  38. package/build-module/entities.mjs +60 -63
  39. package/build-module/entities.mjs.map +2 -2
  40. package/build-module/entity-types/icon.mjs +1 -0
  41. package/build-module/entity-types/icon.mjs.map +7 -0
  42. package/build-module/hooks/use-post-editor-awareness-state.mjs +10 -6
  43. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  44. package/build-module/private-actions.mjs +0 -7
  45. package/build-module/private-actions.mjs.map +2 -2
  46. package/build-module/private-apis.mjs +2 -2
  47. package/build-module/private-apis.mjs.map +1 -1
  48. package/build-module/private-selectors.mjs +2 -12
  49. package/build-module/private-selectors.mjs.map +2 -2
  50. package/build-module/reducer.mjs +0 -9
  51. package/build-module/reducer.mjs.map +2 -2
  52. package/build-module/resolvers.mjs +101 -112
  53. package/build-module/resolvers.mjs.map +2 -2
  54. package/build-module/selectors.mjs.map +2 -2
  55. package/build-module/sync.mjs +0 -2
  56. package/build-module/sync.mjs.map +2 -2
  57. package/build-module/utils/crdt-selection.mjs +1 -1
  58. package/build-module/utils/crdt-selection.mjs.map +2 -2
  59. package/build-module/utils/crdt-user-selections.mjs +77 -22
  60. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  61. package/build-types/actions.d.ts.map +1 -1
  62. package/build-types/awareness/block-lookup.d.ts +29 -0
  63. package/build-types/awareness/block-lookup.d.ts.map +1 -0
  64. package/build-types/awareness/post-editor-awareness.d.ts +18 -5
  65. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  66. package/build-types/awareness/test/block-lookup.d.ts +2 -0
  67. package/build-types/awareness/test/block-lookup.d.ts.map +1 -0
  68. package/build-types/entities.d.ts +16 -0
  69. package/build-types/entities.d.ts.map +1 -1
  70. package/build-types/entity-types/icon.d.ts +25 -0
  71. package/build-types/entity-types/icon.d.ts.map +1 -0
  72. package/build-types/entity-types/index.d.ts +3 -2
  73. package/build-types/entity-types/index.d.ts.map +1 -1
  74. package/build-types/hooks/use-post-editor-awareness-state.d.ts +11 -6
  75. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  76. package/build-types/index.d.ts.map +1 -1
  77. package/build-types/private-actions.d.ts +0 -8
  78. package/build-types/private-actions.d.ts.map +1 -1
  79. package/build-types/private-selectors.d.ts +1 -8
  80. package/build-types/private-selectors.d.ts.map +1 -1
  81. package/build-types/reducer.d.ts +0 -11
  82. package/build-types/reducer.d.ts.map +1 -1
  83. package/build-types/resolvers.d.ts +0 -3
  84. package/build-types/resolvers.d.ts.map +1 -1
  85. package/build-types/selectors.d.ts +0 -6
  86. package/build-types/selectors.d.ts.map +1 -1
  87. package/build-types/sync.d.ts +2 -2
  88. package/build-types/sync.d.ts.map +1 -1
  89. package/build-types/types.d.ts +13 -5
  90. package/build-types/types.d.ts.map +1 -1
  91. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  92. package/build-types/utils/crdt-user-selections.d.ts +21 -4
  93. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  94. package/build-types/utils/test/crdt-user-selections.d.ts +2 -0
  95. package/build-types/utils/test/crdt-user-selections.d.ts.map +1 -0
  96. package/package.json +18 -18
  97. package/src/actions.js +39 -45
  98. package/src/awareness/block-lookup.ts +169 -0
  99. package/src/awareness/post-editor-awareness.ts +68 -11
  100. package/src/awareness/test/block-lookup.ts +504 -0
  101. package/src/awareness/test/post-editor-awareness.ts +662 -38
  102. package/src/entities.js +63 -66
  103. package/src/entity-types/icon.ts +30 -0
  104. package/src/entity-types/index.ts +3 -0
  105. package/src/hooks/test/use-post-editor-awareness-state.ts +21 -14
  106. package/src/hooks/use-post-editor-awareness-state.ts +22 -13
  107. package/src/private-actions.js +0 -14
  108. package/src/private-apis.js +2 -2
  109. package/src/private-selectors.ts +3 -22
  110. package/src/reducer.js +0 -17
  111. package/src/resolvers.js +137 -156
  112. package/src/selectors.ts +0 -7
  113. package/src/sync.ts +0 -2
  114. package/src/test/resolvers.js +109 -1
  115. package/src/types.ts +22 -5
  116. package/src/utils/crdt-selection.ts +3 -1
  117. package/src/utils/crdt-user-selections.ts +129 -47
  118. package/src/utils/test/crdt-user-selections.ts +894 -0
@@ -9,7 +9,11 @@ import { dispatch, select, subscribe, resolveSelect } from '@wordpress/data';
9
9
  */
10
10
  import { PostEditorAwareness } from '../post-editor-awareness';
11
11
  import { SelectionType } from '../../utils/crdt-user-selections';
12
- import type { SelectionNone, SelectionCursor } from '../../types';
12
+ import type {
13
+ SelectionNone,
14
+ SelectionCursor,
15
+ SelectionWholeBlock,
16
+ } from '../../types';
13
17
  import { CRDT_RECORD_MAP_KEY } from '../../sync';
14
18
  import type { CollaboratorInfo } from '../types';
15
19
 
@@ -46,23 +50,117 @@ const createMockUser = () => ( {
46
50
  avatar_urls: mockAvatarUrls,
47
51
  } );
48
52
 
53
+ type MockBlock = {
54
+ clientId: string;
55
+ name?: string;
56
+ innerBlocks: MockBlock[];
57
+ };
58
+
59
+ interface MockBlockEditorOverrides {
60
+ blocks?: MockBlock[];
61
+ getBlocks?: jest.Mock;
62
+ getBlockName?: string;
63
+ getSelectionStart?: jest.Mock;
64
+ getSelectionEnd?: jest.Mock;
65
+ }
66
+
49
67
  /**
50
- * Helper function to create a Y.Doc with blocks structure for testing
68
+ * Mock the block-editor store selectors returned by `select( blockEditorStore )`.
69
+ *
70
+ * Only the fields that vary between tests need to be passed — everything else
71
+ * gets sensible defaults. Pass `blocks` for the common case (static return
72
+ * value) or `getBlocks` when you need `mockImplementation` (e.g. template mode).
73
+ *
74
+ * Returns `{ getBlocks }` so callers can assert on it (e.g. `toHaveBeenCalledWith`).
75
+ *
76
+ * @param overrides - Optional selector overrides.
51
77
  */
52
- function createTestDocWithBlocks() {
53
- const ydoc = new Y.Doc();
54
- const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
55
- const blocks = new Y.Array();
56
- documentMap.set( 'blocks', blocks );
78
+ function mockBlockEditorStore( overrides: MockBlockEditorOverrides = {} ) {
79
+ const defaultBlocks = [
80
+ {
81
+ clientId: 'block-1',
82
+ name: 'core/paragraph',
83
+ innerBlocks: [],
84
+ },
85
+ ];
86
+
87
+ const getBlocks =
88
+ overrides.getBlocks ??
89
+ jest.fn().mockReturnValue( overrides.blocks ?? defaultBlocks );
90
+
91
+ ( select as jest.Mock ).mockReturnValue( {
92
+ getSelectionStart:
93
+ overrides.getSelectionStart ?? jest.fn().mockReturnValue( {} ),
94
+ getSelectionEnd:
95
+ overrides.getSelectionEnd ?? jest.fn().mockReturnValue( {} ),
96
+ getSelectedBlocksInitialCaretPosition: jest
97
+ .fn()
98
+ .mockReturnValue( null ),
99
+ getBlockIndex: jest.fn().mockReturnValue( 0 ),
100
+ getBlockRootClientId: jest.fn().mockReturnValue( '' ),
101
+ getBlockName: jest
102
+ .fn()
103
+ .mockReturnValue( overrides.getBlockName ?? 'core/paragraph' ),
104
+ getBlocks,
105
+ } );
57
106
 
58
- // Create a block with content
107
+ return { getBlocks };
108
+ }
109
+
110
+ /**
111
+ * Helper to create a single Yjs block with optional text content and inner blocks.
112
+ * @param clientId
113
+ * @param name
114
+ * @param options
115
+ * @param options.textContent
116
+ * @param options.innerBlocks
117
+ */
118
+ function createYBlock(
119
+ clientId: string,
120
+ name: string,
121
+ {
122
+ textContent,
123
+ innerBlocks = [],
124
+ }: { textContent?: string; innerBlocks?: Y.Map< any >[] } = {}
125
+ ): Y.Map< any > {
59
126
  const block = new Y.Map();
60
- block.set( 'clientId', 'block-1' );
127
+ block.set( 'clientId', clientId );
128
+ block.set( 'name', name );
129
+
61
130
  const attrs = new Y.Map();
62
- attrs.set( 'content', new Y.Text( 'Hello world' ) );
131
+ if ( textContent !== undefined ) {
132
+ attrs.set( 'content', new Y.Text( textContent ) );
133
+ }
134
+
63
135
  block.set( 'attributes', attrs );
64
- block.set( 'innerBlocks', new Y.Array() );
65
- blocks.push( [ block ] );
136
+ const inner = new Y.Array();
137
+ if ( innerBlocks.length ) {
138
+ inner.push( innerBlocks );
139
+ }
140
+
141
+ block.set( 'innerBlocks', inner );
142
+ return block;
143
+ }
144
+
145
+ /**
146
+ * Helper function to create a Y.Doc with blocks structure for testing
147
+ * @param blocks
148
+ */
149
+ function createTestDocWithBlocks( blocks?: Y.Map< any >[] ) {
150
+ const ydoc = new Y.Doc();
151
+ const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
152
+ const yBlocks = new Y.Array();
153
+ documentMap.set( 'blocks', yBlocks );
154
+
155
+ if ( blocks ) {
156
+ yBlocks.push( blocks );
157
+ } else {
158
+ // Default: single block with content
159
+ const block = createYBlock( 'block-1', 'core/paragraph', {
160
+ textContent: 'Hello world',
161
+ } );
162
+ yBlocks.push( [ block ] );
163
+ }
66
164
 
67
165
  return ydoc;
68
166
  }
@@ -82,14 +180,7 @@ describe( 'PostEditorAwareness', () => {
82
180
 
83
181
  jest.spyOn( Date, 'now' ).mockReturnValue( 1704067200000 );
84
182
 
85
- // Mock select to return block editor selectors
86
- ( select as jest.Mock ).mockReturnValue( {
87
- getSelectionStart: jest.fn().mockReturnValue( {} ),
88
- getSelectionEnd: jest.fn().mockReturnValue( {} ),
89
- getSelectedBlocksInitialCaretPosition: jest
90
- .fn()
91
- .mockReturnValue( null ),
92
- } );
183
+ mockBlockEditorStore();
93
184
 
94
185
  // Mock subscribe to capture the callback
95
186
  ( subscribe as jest.Mock ).mockImplementation( ( callback ) => {
@@ -205,12 +296,9 @@ describe( 'PostEditorAwareness', () => {
205
296
  offset: 5,
206
297
  } );
207
298
 
208
- ( select as jest.Mock ).mockReturnValue( {
299
+ mockBlockEditorStore( {
209
300
  getSelectionStart: mockGetSelectionStart,
210
301
  getSelectionEnd: mockGetSelectionEnd,
211
- getSelectedBlocksInitialCaretPosition: jest
212
- .fn()
213
- .mockReturnValue( null ),
214
302
  } );
215
303
 
216
304
  const awareness = new PostEditorAwareness(
@@ -257,12 +345,9 @@ describe( 'PostEditorAwareness', () => {
257
345
  offset: 5,
258
346
  } );
259
347
 
260
- ( select as jest.Mock ).mockReturnValue( {
348
+ mockBlockEditorStore( {
261
349
  getSelectionStart: mockGetSelectionStart,
262
350
  getSelectionEnd: mockGetSelectionEnd,
263
- getSelectedBlocksInitialCaretPosition: jest
264
- .fn()
265
- .mockReturnValue( null ),
266
351
  } );
267
352
 
268
353
  const awareness = new PostEditorAwareness(
@@ -339,8 +424,8 @@ describe( 'PostEditorAwareness', () => {
339
424
  } );
340
425
  } );
341
426
 
342
- describe( 'getAbsolutePositionIndex', () => {
343
- test( 'should return null when relative position cannot be resolved', () => {
427
+ describe( 'convertSelectionStateToAbsolute', () => {
428
+ test( 'should return nulls when relative position cannot be resolved', () => {
344
429
  const awareness = new PostEditorAwareness(
345
430
  doc,
346
431
  'postType',
@@ -361,20 +446,21 @@ describe( 'PostEditorAwareness', () => {
361
446
 
362
447
  const selection: SelectionCursor = {
363
448
  type: SelectionType.Cursor,
364
- blockId: 'block-1',
365
449
  cursorPosition: {
366
450
  relativePosition,
367
451
  absoluteOffset: 2,
368
452
  },
369
453
  };
370
454
 
371
- const result = awareness.getAbsolutePositionIndex( selection );
455
+ const result =
456
+ awareness.convertSelectionStateToAbsolute( selection );
372
457
 
373
- // Should return null when the relative position's type cannot be found
374
- expect( result ).toBeNull();
458
+ // Should return nulls when the relative position's type cannot be found
459
+ expect( result.textIndex ).toBeNull();
460
+ expect( result.localClientId ).toBeNull();
375
461
  } );
376
462
 
377
- test( 'should return absolute position index for valid selection', () => {
463
+ test( 'should return text index and block client ID for valid cursor selection', () => {
378
464
  const awareness = new PostEditorAwareness(
379
465
  doc,
380
466
  'postType',
@@ -399,16 +485,49 @@ describe( 'PostEditorAwareness', () => {
399
485
 
400
486
  const selection: SelectionCursor = {
401
487
  type: SelectionType.Cursor,
402
- blockId: 'block-1',
403
488
  cursorPosition: {
404
489
  relativePosition,
405
490
  absoluteOffset: 5,
406
491
  },
407
492
  };
408
493
 
409
- const result = awareness.getAbsolutePositionIndex( selection );
494
+ const result =
495
+ awareness.convertSelectionStateToAbsolute( selection );
496
+
497
+ expect( result.textIndex ).toBe( 5 );
498
+ expect( result.localClientId ).toBe( 'block-1' );
499
+ } );
500
+
501
+ test( 'should resolve WholeBlock selection to block client ID', () => {
502
+ const awareness = new PostEditorAwareness(
503
+ doc,
504
+ 'postType',
505
+ 'post',
506
+ 123
507
+ );
508
+
509
+ // Get the blocks array from the doc
510
+ const documentMap = doc.getMap( CRDT_RECORD_MAP_KEY );
511
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
512
+ Y.Map< any >
513
+ >;
514
+
515
+ // Create a block relative position
516
+ const blockPosition = Y.createRelativePositionFromTypeIndex(
517
+ blocks,
518
+ 0
519
+ );
520
+
521
+ const selection: SelectionWholeBlock = {
522
+ type: SelectionType.WholeBlock,
523
+ blockPosition,
524
+ };
525
+
526
+ const result =
527
+ awareness.convertSelectionStateToAbsolute( selection );
410
528
 
411
- expect( result ).toBe( 5 );
529
+ expect( result.textIndex ).toBeNull();
530
+ expect( result.localClientId ).toBe( 'block-1' );
412
531
  } );
413
532
  } );
414
533
 
@@ -558,4 +677,509 @@ describe( 'PostEditorAwareness', () => {
558
677
  expect( callback ).toHaveBeenCalled();
559
678
  } );
560
679
  } );
680
+
681
+ describe( 'convertSelectionStateToAbsolute with nested blocks', () => {
682
+ test( 'should resolve cursor in second root block (path [1])', () => {
683
+ const nestedDoc = createTestDocWithBlocks( [
684
+ createYBlock( 'yjs-block-0', 'core/paragraph', {
685
+ textContent: 'First',
686
+ } ),
687
+ createYBlock( 'yjs-block-1', 'core/paragraph', {
688
+ textContent: 'Second',
689
+ } ),
690
+ createYBlock( 'yjs-block-2', 'core/paragraph', {
691
+ textContent: 'Third',
692
+ } ),
693
+ ] );
694
+
695
+ mockBlockEditorStore( {
696
+ blocks: [
697
+ { clientId: 'local-0', innerBlocks: [] },
698
+ { clientId: 'local-1', innerBlocks: [] },
699
+ { clientId: 'local-2', innerBlocks: [] },
700
+ ],
701
+ } );
702
+
703
+ const awareness = new PostEditorAwareness(
704
+ nestedDoc,
705
+ 'postType',
706
+ 'post',
707
+ 123
708
+ );
709
+
710
+ // Create a cursor in the third block's text
711
+ const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
712
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
713
+ Y.Map< any >
714
+ >;
715
+ const block2 = blocks.get( 2 );
716
+ const attrs2 = block2.get( 'attributes' ) as Y.Map< Y.Text >;
717
+ const yText2 = attrs2.get( 'content' ) as Y.Text;
718
+
719
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
720
+ yText2,
721
+ 2
722
+ );
723
+
724
+ const selection: SelectionCursor = {
725
+ type: SelectionType.Cursor,
726
+ cursorPosition: {
727
+ relativePosition,
728
+ absoluteOffset: 2,
729
+ },
730
+ };
731
+
732
+ const result =
733
+ awareness.convertSelectionStateToAbsolute( selection );
734
+
735
+ expect( result.textIndex ).toBe( 2 );
736
+ expect( result.localClientId ).toBe( 'local-2' );
737
+
738
+ nestedDoc.destroy();
739
+ } );
740
+
741
+ test( 'should resolve cursor in a nested inner block (path [0, 1])', () => {
742
+ const innerParagraph0 = createYBlock(
743
+ 'yjs-inner-0',
744
+ 'core/paragraph',
745
+ { textContent: 'Inner zero' }
746
+ );
747
+ const innerParagraph1 = createYBlock(
748
+ 'yjs-inner-1',
749
+ 'core/paragraph',
750
+ { textContent: 'Inner one' }
751
+ );
752
+ const outerColumn = createYBlock( 'yjs-outer', 'core/column', {
753
+ innerBlocks: [ innerParagraph0, innerParagraph1 ],
754
+ } );
755
+
756
+ const nestedDoc = createTestDocWithBlocks( [ outerColumn ] );
757
+
758
+ mockBlockEditorStore( {
759
+ blocks: [
760
+ {
761
+ clientId: 'local-outer',
762
+ innerBlocks: [
763
+ { clientId: 'local-inner-0', innerBlocks: [] },
764
+ { clientId: 'local-inner-1', innerBlocks: [] },
765
+ ],
766
+ },
767
+ ],
768
+ } );
769
+
770
+ const awareness = new PostEditorAwareness(
771
+ nestedDoc,
772
+ 'postType',
773
+ 'post',
774
+ 123
775
+ );
776
+
777
+ // Create cursor in the second inner paragraph
778
+ const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
779
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
780
+ Y.Map< any >
781
+ >;
782
+ const outer = blocks.get( 0 );
783
+ const innerBlocks = outer.get( 'innerBlocks' ) as Y.Array<
784
+ Y.Map< any >
785
+ >;
786
+ const innerBlock1 = innerBlocks.get( 1 );
787
+ const innerAttrs = innerBlock1.get(
788
+ 'attributes'
789
+ ) as Y.Map< Y.Text >;
790
+ const yText = innerAttrs.get( 'content' ) as Y.Text;
791
+
792
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
793
+ yText,
794
+ 5
795
+ );
796
+
797
+ const selection: SelectionCursor = {
798
+ type: SelectionType.Cursor,
799
+ cursorPosition: {
800
+ relativePosition,
801
+ absoluteOffset: 5,
802
+ },
803
+ };
804
+
805
+ const result =
806
+ awareness.convertSelectionStateToAbsolute( selection );
807
+
808
+ expect( result.textIndex ).toBe( 5 );
809
+ expect( result.localClientId ).toBe( 'local-inner-1' );
810
+
811
+ nestedDoc.destroy();
812
+ } );
813
+
814
+ test( 'should resolve WholeBlock for a nested image block', () => {
815
+ const innerImage = createYBlock( 'yjs-img', 'core/image' );
816
+ const outerColumn = createYBlock( 'yjs-col', 'core/column', {
817
+ innerBlocks: [ innerImage ],
818
+ } );
819
+
820
+ const nestedDoc = createTestDocWithBlocks( [ outerColumn ] );
821
+
822
+ mockBlockEditorStore( {
823
+ blocks: [
824
+ {
825
+ clientId: 'local-col',
826
+ innerBlocks: [
827
+ { clientId: 'local-img', innerBlocks: [] },
828
+ ],
829
+ },
830
+ ],
831
+ } );
832
+
833
+ const awareness = new PostEditorAwareness(
834
+ nestedDoc,
835
+ 'postType',
836
+ 'post',
837
+ 123
838
+ );
839
+
840
+ // Create a WholeBlock relative position for the inner image
841
+ const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
842
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
843
+ Y.Map< any >
844
+ >;
845
+ const outer = blocks.get( 0 );
846
+ const innerBlocks = outer.get( 'innerBlocks' ) as Y.Array<
847
+ Y.Map< any >
848
+ >;
849
+
850
+ const blockPosition = Y.createRelativePositionFromTypeIndex(
851
+ innerBlocks,
852
+ 0
853
+ );
854
+
855
+ const selection: SelectionWholeBlock = {
856
+ type: SelectionType.WholeBlock,
857
+ blockPosition,
858
+ };
859
+
860
+ const result =
861
+ awareness.convertSelectionStateToAbsolute( selection );
862
+
863
+ expect( result.textIndex ).toBeNull();
864
+ expect( result.localClientId ).toBe( 'local-img' );
865
+
866
+ nestedDoc.destroy();
867
+ } );
868
+
869
+ test( 'should resolve a deeply nested block (path [1, 0, 1])', () => {
870
+ const deepParagraph0 = createYBlock(
871
+ 'yjs-deep-0',
872
+ 'core/paragraph',
873
+ { textContent: 'Deep zero' }
874
+ );
875
+ const deepParagraph1 = createYBlock(
876
+ 'yjs-deep-1',
877
+ 'core/paragraph',
878
+ { textContent: 'Deep one content' }
879
+ );
880
+ const midColumn = createYBlock( 'yjs-mid', 'core/column', {
881
+ innerBlocks: [ deepParagraph0, deepParagraph1 ],
882
+ } );
883
+ const outerColumns0 = createYBlock( 'yjs-outer-0', 'core/columns' );
884
+ const outerColumns1 = createYBlock( 'yjs-outer-1', 'core/columns', {
885
+ innerBlocks: [ midColumn ],
886
+ } );
887
+
888
+ const nestedDoc = createTestDocWithBlocks( [
889
+ outerColumns0,
890
+ outerColumns1,
891
+ ] );
892
+
893
+ mockBlockEditorStore( {
894
+ blocks: [
895
+ { clientId: 'local-outer-0', innerBlocks: [] },
896
+ {
897
+ clientId: 'local-outer-1',
898
+ innerBlocks: [
899
+ {
900
+ clientId: 'local-mid',
901
+ innerBlocks: [
902
+ {
903
+ clientId: 'local-deep-0',
904
+ innerBlocks: [],
905
+ },
906
+ {
907
+ clientId: 'local-deep-1',
908
+ innerBlocks: [],
909
+ },
910
+ ],
911
+ },
912
+ ],
913
+ },
914
+ ],
915
+ } );
916
+
917
+ const awareness = new PostEditorAwareness(
918
+ nestedDoc,
919
+ 'postType',
920
+ 'post',
921
+ 123
922
+ );
923
+
924
+ // Create cursor in the deeply nested second paragraph
925
+ const documentMap = nestedDoc.getMap( CRDT_RECORD_MAP_KEY );
926
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
927
+ Y.Map< any >
928
+ >;
929
+ const outer1 = blocks.get( 1 );
930
+ const outer1Inner = outer1.get( 'innerBlocks' ) as Y.Array<
931
+ Y.Map< any >
932
+ >;
933
+ const mid = outer1Inner.get( 0 );
934
+ const midInner = mid.get( 'innerBlocks' ) as Y.Array<
935
+ Y.Map< any >
936
+ >;
937
+ const deep1 = midInner.get( 1 );
938
+ const deep1Attrs = deep1.get( 'attributes' ) as Y.Map< Y.Text >;
939
+ const yText = deep1Attrs.get( 'content' ) as Y.Text;
940
+
941
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
942
+ yText,
943
+ 7
944
+ );
945
+
946
+ const selection: SelectionCursor = {
947
+ type: SelectionType.Cursor,
948
+ cursorPosition: {
949
+ relativePosition,
950
+ absoluteOffset: 7,
951
+ },
952
+ };
953
+
954
+ const result =
955
+ awareness.convertSelectionStateToAbsolute( selection );
956
+
957
+ expect( result.textIndex ).toBe( 7 );
958
+ expect( result.localClientId ).toBe( 'local-deep-1' );
959
+
960
+ nestedDoc.destroy();
961
+ } );
962
+ } );
963
+
964
+ describe( 'template mode (core/post-content handling)', () => {
965
+ test( 'should resolve cursor when getBlocks returns template tree with core/post-content', () => {
966
+ // Yjs doc has only the post content blocks (no template wrapper)
967
+ const templateDoc = createTestDocWithBlocks( [
968
+ createYBlock( 'yjs-para-0', 'core/paragraph', {
969
+ textContent: 'Post paragraph 1',
970
+ } ),
971
+ createYBlock( 'yjs-para-1', 'core/paragraph', {
972
+ textContent: 'Post paragraph 2',
973
+ } ),
974
+ ] );
975
+
976
+ // In template mode, getBlocks() returns the full template tree.
977
+ // The Yjs paths are relative to post content, so the receiver needs
978
+ // to find core/post-content and navigate from there.
979
+ const postContentClientId = 'local-post-content';
980
+ const mockGetBlocks = jest
981
+ .fn()
982
+ .mockImplementation( ( rootClientId?: string ) => {
983
+ if ( rootClientId === postContentClientId ) {
984
+ // Controlled inner blocks of core/post-content
985
+ return [
986
+ {
987
+ clientId: 'local-para-0',
988
+ name: 'core/paragraph',
989
+ innerBlocks: [],
990
+ },
991
+ {
992
+ clientId: 'local-para-1',
993
+ name: 'core/paragraph',
994
+ innerBlocks: [],
995
+ },
996
+ ];
997
+ }
998
+ // Full template tree
999
+ return [
1000
+ {
1001
+ clientId: 'local-header',
1002
+ name: 'core/template-part',
1003
+ innerBlocks: [],
1004
+ },
1005
+ {
1006
+ clientId: 'local-group',
1007
+ name: 'core/group',
1008
+ innerBlocks: [
1009
+ {
1010
+ clientId: postContentClientId,
1011
+ name: 'core/post-content',
1012
+ innerBlocks: [], // empty because they're controlled inner blocks
1013
+ },
1014
+ ],
1015
+ },
1016
+ {
1017
+ clientId: 'local-footer',
1018
+ name: 'core/template-part',
1019
+ innerBlocks: [],
1020
+ },
1021
+ ];
1022
+ } );
1023
+
1024
+ mockBlockEditorStore( { getBlocks: mockGetBlocks } );
1025
+
1026
+ const awareness = new PostEditorAwareness(
1027
+ templateDoc,
1028
+ 'postType',
1029
+ 'post',
1030
+ 123
1031
+ );
1032
+
1033
+ // Create cursor in the second post content paragraph
1034
+ const documentMap = templateDoc.getMap( CRDT_RECORD_MAP_KEY );
1035
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
1036
+ Y.Map< any >
1037
+ >;
1038
+ const block1 = blocks.get( 1 );
1039
+ const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >;
1040
+ const yText = attrs.get( 'content' ) as Y.Text;
1041
+
1042
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
1043
+ yText,
1044
+ 4
1045
+ );
1046
+
1047
+ const selection: SelectionCursor = {
1048
+ type: SelectionType.Cursor,
1049
+ cursorPosition: {
1050
+ relativePosition,
1051
+ absoluteOffset: 4,
1052
+ },
1053
+ };
1054
+
1055
+ const result =
1056
+ awareness.convertSelectionStateToAbsolute( selection );
1057
+
1058
+ expect( result.textIndex ).toBe( 4 );
1059
+ // Should resolve to the post-content inner block, not a template block
1060
+ expect( result.localClientId ).toBe( 'local-para-1' );
1061
+ // Verify getBlocks was called with the post-content clientId
1062
+ expect( mockGetBlocks ).toHaveBeenCalledWith( postContentClientId );
1063
+
1064
+ templateDoc.destroy();
1065
+ } );
1066
+
1067
+ test( 'should resolve WholeBlock in template mode', () => {
1068
+ const templateDoc = createTestDocWithBlocks( [
1069
+ createYBlock( 'yjs-img', 'core/image' ),
1070
+ ] );
1071
+
1072
+ const postContentClientId = 'local-post-content';
1073
+ const mockGetBlocks = jest
1074
+ .fn()
1075
+ .mockImplementation( ( rootClientId?: string ) => {
1076
+ if ( rootClientId === postContentClientId ) {
1077
+ return [
1078
+ {
1079
+ clientId: 'local-img',
1080
+ name: 'core/image',
1081
+ innerBlocks: [],
1082
+ },
1083
+ ];
1084
+ }
1085
+ return [
1086
+ {
1087
+ clientId: 'local-group',
1088
+ name: 'core/group',
1089
+ innerBlocks: [
1090
+ {
1091
+ clientId: postContentClientId,
1092
+ name: 'core/post-content',
1093
+ innerBlocks: [],
1094
+ },
1095
+ ],
1096
+ },
1097
+ ];
1098
+ } );
1099
+
1100
+ mockBlockEditorStore( {
1101
+ getBlocks: mockGetBlocks,
1102
+ getBlockName: 'core/image',
1103
+ } );
1104
+
1105
+ const awareness = new PostEditorAwareness(
1106
+ templateDoc,
1107
+ 'postType',
1108
+ 'post',
1109
+ 123
1110
+ );
1111
+
1112
+ const documentMap = templateDoc.getMap( CRDT_RECORD_MAP_KEY );
1113
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
1114
+ Y.Map< any >
1115
+ >;
1116
+
1117
+ const blockPosition = Y.createRelativePositionFromTypeIndex(
1118
+ blocks,
1119
+ 0
1120
+ );
1121
+
1122
+ const selection: SelectionWholeBlock = {
1123
+ type: SelectionType.WholeBlock,
1124
+ blockPosition,
1125
+ };
1126
+
1127
+ const result =
1128
+ awareness.convertSelectionStateToAbsolute( selection );
1129
+
1130
+ expect( result.textIndex ).toBeNull();
1131
+ expect( result.localClientId ).toBe( 'local-img' );
1132
+
1133
+ templateDoc.destroy();
1134
+ } );
1135
+
1136
+ test( 'should fall back to root blocks when no core/post-content exists', () => {
1137
+ // Normal mode (no template) — should use root blocks directly
1138
+ const normalDoc = createTestDocWithBlocks( [
1139
+ createYBlock( 'yjs-para', 'core/paragraph', {
1140
+ textContent: 'Normal mode',
1141
+ } ),
1142
+ ] );
1143
+
1144
+ mockBlockEditorStore( {
1145
+ blocks: [ { clientId: 'local-para', innerBlocks: [] } ],
1146
+ } );
1147
+
1148
+ const awareness = new PostEditorAwareness(
1149
+ normalDoc,
1150
+ 'postType',
1151
+ 'post',
1152
+ 123
1153
+ );
1154
+
1155
+ const documentMap = normalDoc.getMap( CRDT_RECORD_MAP_KEY );
1156
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
1157
+ Y.Map< any >
1158
+ >;
1159
+ const block = blocks.get( 0 );
1160
+ const attrs = block.get( 'attributes' ) as Y.Map< Y.Text >;
1161
+ const yText = attrs.get( 'content' ) as Y.Text;
1162
+
1163
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
1164
+ yText,
1165
+ 3
1166
+ );
1167
+
1168
+ const selection: SelectionCursor = {
1169
+ type: SelectionType.Cursor,
1170
+ cursorPosition: {
1171
+ relativePosition,
1172
+ absoluteOffset: 3,
1173
+ },
1174
+ };
1175
+
1176
+ const result =
1177
+ awareness.convertSelectionStateToAbsolute( selection );
1178
+
1179
+ expect( result.textIndex ).toBe( 3 );
1180
+ expect( result.localClientId ).toBe( 'local-para' );
1181
+
1182
+ normalDoc.destroy();
1183
+ } );
1184
+ } );
561
1185
  } );