@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
@@ -0,0 +1,894 @@
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
+ areSelectionsStatesEqual,
12
+ getSelectionState,
13
+ SelectionType,
14
+ } from '../crdt-user-selections';
15
+ import { CRDT_RECORD_MAP_KEY } from '../../sync';
16
+
17
+ jest.mock( '@wordpress/data', () => ( {
18
+ select: jest.fn(),
19
+ } ) );
20
+
21
+ jest.mock( '@wordpress/block-editor', () => ( {
22
+ store: 'core/block-editor',
23
+ } ) );
24
+ import type {
25
+ CursorPosition,
26
+ SelectionNone,
27
+ SelectionCursor,
28
+ SelectionInOneBlock,
29
+ SelectionInMultipleBlocks,
30
+ SelectionWholeBlock,
31
+ SelectionState,
32
+ WPBlockSelection,
33
+ } from '../../types';
34
+
35
+ // Shared Y.Doc and Y.Map for creating Y.Text instances
36
+ const yDoc = new Y.Doc();
37
+ const yMap = yDoc.getMap( 'test-map' );
38
+ let textCounter = 0;
39
+ let blockCounter = 0;
40
+
41
+ /**
42
+ * Helper to create a Y.Text instance attached to a Y.Doc.
43
+ * Y.Text objects must be attached to a Y.Doc to create relative positions.
44
+ *
45
+ * @param yTextValue - The value of the Y.Text instance.
46
+ * @param yTextKey - The key of the Y.Text instance.
47
+ * @return The Y.Text instance, attached to the Y.Doc.
48
+ */
49
+ function createYText( yTextValue: string, yTextKey?: string ): Y.Text {
50
+ textCounter++;
51
+ const key = yTextKey ?? `test-text-${ textCounter }`;
52
+ const yText = new Y.Text( yTextValue );
53
+ yMap.set( key, yText );
54
+ return yText;
55
+ }
56
+
57
+ /**
58
+ * Helper to create a Y.RelativePosition pointing to an index in a Y.Array.
59
+ *
60
+ * @param index - The index in the Y.Array.
61
+ * @return The Y.RelativePosition.
62
+ */
63
+ function createBlockPosition( index: number ): Y.RelativePosition {
64
+ blockCounter++;
65
+ const yArray = new Y.Array();
66
+ yMap.set( `test-array-${ blockCounter }`, yArray );
67
+
68
+ for ( let i = 0; i <= index; i++ ) {
69
+ yArray.push( [ new Y.Map() ] );
70
+ }
71
+
72
+ return Y.createRelativePositionFromTypeIndex( yArray, index );
73
+ }
74
+
75
+ /**
76
+ * Helper to create a CursorPosition with a relative position.
77
+ *
78
+ * @param text - The text of the Y.Text instance.
79
+ * @param offset - The offset of the Y.Text instance.
80
+ * @return The CursorPosition.
81
+ */
82
+ function createCursorPosition( text: string, offset: number ): CursorPosition {
83
+ const yText = createYText( text );
84
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
85
+ yText,
86
+ offset
87
+ );
88
+
89
+ return {
90
+ relativePosition,
91
+ absoluteOffset: offset,
92
+ };
93
+ }
94
+
95
+ describe( 'areSelectionsStatesEqual', () => {
96
+ describe( 'different selection types', () => {
97
+ test( 'returns false when comparing different selection types', () => {
98
+ const selection1: SelectionNone = { type: SelectionType.None };
99
+ const selection2: SelectionCursor = {
100
+ type: SelectionType.Cursor,
101
+ cursorPosition: createCursorPosition( 'test', 0 ),
102
+ };
103
+
104
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
105
+ false
106
+ );
107
+ } );
108
+ } );
109
+
110
+ describe( 'SelectionType.None', () => {
111
+ test( 'returns true when both selections are None', () => {
112
+ const selection1: SelectionNone = { type: SelectionType.None };
113
+ const selection2: SelectionNone = { type: SelectionType.None };
114
+
115
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
116
+ true
117
+ );
118
+ } );
119
+ } );
120
+
121
+ describe( 'SelectionType.Cursor', () => {
122
+ test( 'returns true when cursor selections are identical', () => {
123
+ const cursorPosition = createCursorPosition( 'test text', 5 );
124
+ const selection1: SelectionCursor = {
125
+ type: SelectionType.Cursor,
126
+ cursorPosition,
127
+ };
128
+ const selection2: SelectionCursor = {
129
+ type: SelectionType.Cursor,
130
+ cursorPosition,
131
+ };
132
+
133
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
134
+ true
135
+ );
136
+ } );
137
+
138
+ test( 'returns false when relative position differs', () => {
139
+ const selection1: SelectionCursor = {
140
+ type: SelectionType.Cursor,
141
+ cursorPosition: createCursorPosition( 'test text', 5 ),
142
+ };
143
+ const selection2: SelectionCursor = {
144
+ type: SelectionType.Cursor,
145
+ cursorPosition: createCursorPosition( 'test text', 3 ),
146
+ };
147
+
148
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
149
+ false
150
+ );
151
+ } );
152
+
153
+ test( 'returns false when absolute offset differs', () => {
154
+ const yText = createYText( 'test text' );
155
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
156
+ yText,
157
+ 5
158
+ );
159
+
160
+ const selection1: SelectionCursor = {
161
+ type: SelectionType.Cursor,
162
+ cursorPosition: {
163
+ relativePosition,
164
+ absoluteOffset: 5,
165
+ },
166
+ };
167
+ const selection2: SelectionCursor = {
168
+ type: SelectionType.Cursor,
169
+ cursorPosition: {
170
+ relativePosition,
171
+ absoluteOffset: 6,
172
+ },
173
+ };
174
+
175
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
176
+ false
177
+ );
178
+ } );
179
+ } );
180
+
181
+ describe( 'SelectionType.SelectionInOneBlock', () => {
182
+ test( 'returns true when selections in one block are identical', () => {
183
+ const cursorStartPosition = createCursorPosition( 'test text', 0 );
184
+ const cursorEndPosition = createCursorPosition( 'test text', 4 );
185
+ const selection1: SelectionInOneBlock = {
186
+ type: SelectionType.SelectionInOneBlock,
187
+ cursorStartPosition,
188
+ cursorEndPosition,
189
+ };
190
+ const selection2: SelectionInOneBlock = {
191
+ type: SelectionType.SelectionInOneBlock,
192
+ cursorStartPosition,
193
+ cursorEndPosition,
194
+ };
195
+
196
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
197
+ true
198
+ );
199
+ } );
200
+
201
+ test( 'returns false when start position differs', () => {
202
+ const selection1: SelectionInOneBlock = {
203
+ type: SelectionType.SelectionInOneBlock,
204
+ cursorStartPosition: createCursorPosition( 'test text', 0 ),
205
+ cursorEndPosition: createCursorPosition( 'test text', 4 ),
206
+ };
207
+ const selection2: SelectionInOneBlock = {
208
+ type: SelectionType.SelectionInOneBlock,
209
+ cursorStartPosition: createCursorPosition( 'test text', 1 ),
210
+ cursorEndPosition: createCursorPosition( 'test text', 4 ),
211
+ };
212
+
213
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
214
+ false
215
+ );
216
+ } );
217
+
218
+ test( 'returns false when end position differs', () => {
219
+ const selection1: SelectionInOneBlock = {
220
+ type: SelectionType.SelectionInOneBlock,
221
+ cursorStartPosition: createCursorPosition( 'test text', 0 ),
222
+ cursorEndPosition: createCursorPosition( 'test text', 4 ),
223
+ };
224
+ const selection2: SelectionInOneBlock = {
225
+ type: SelectionType.SelectionInOneBlock,
226
+ cursorStartPosition: createCursorPosition( 'test text', 0 ),
227
+ cursorEndPosition: createCursorPosition( 'test text', 5 ),
228
+ };
229
+
230
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
231
+ false
232
+ );
233
+ } );
234
+
235
+ test( 'returns false when absolute offset differs in start position', () => {
236
+ const yText = createYText( 'test text' );
237
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
238
+ yText,
239
+ 0
240
+ );
241
+ const cursorEndPosition = createCursorPosition( 'test text', 4 );
242
+
243
+ const selection1: SelectionInOneBlock = {
244
+ type: SelectionType.SelectionInOneBlock,
245
+ cursorStartPosition: {
246
+ relativePosition,
247
+ absoluteOffset: 0,
248
+ },
249
+ cursorEndPosition,
250
+ };
251
+ const selection2: SelectionInOneBlock = {
252
+ type: SelectionType.SelectionInOneBlock,
253
+ cursorStartPosition: {
254
+ relativePosition,
255
+ absoluteOffset: 1,
256
+ },
257
+ cursorEndPosition,
258
+ };
259
+
260
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
261
+ false
262
+ );
263
+ } );
264
+ } );
265
+
266
+ describe( 'SelectionType.SelectionInMultipleBlocks', () => {
267
+ test( 'returns true when selections in multiple blocks are identical', () => {
268
+ const cursorStartPosition = createCursorPosition(
269
+ 'first block',
270
+ 5
271
+ );
272
+ const cursorEndPosition = createCursorPosition( 'second block', 3 );
273
+ const selection1: SelectionInMultipleBlocks = {
274
+ type: SelectionType.SelectionInMultipleBlocks,
275
+ cursorStartPosition,
276
+ cursorEndPosition,
277
+ };
278
+ const selection2: SelectionInMultipleBlocks = {
279
+ type: SelectionType.SelectionInMultipleBlocks,
280
+ cursorStartPosition,
281
+ cursorEndPosition,
282
+ };
283
+
284
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
285
+ true
286
+ );
287
+ } );
288
+
289
+ test( 'returns false when start cursor position differs', () => {
290
+ const selection1: SelectionInMultipleBlocks = {
291
+ type: SelectionType.SelectionInMultipleBlocks,
292
+ cursorStartPosition: createCursorPosition( 'first block', 5 ),
293
+ cursorEndPosition: createCursorPosition( 'second block', 3 ),
294
+ };
295
+ const selection2: SelectionInMultipleBlocks = {
296
+ type: SelectionType.SelectionInMultipleBlocks,
297
+ cursorStartPosition: createCursorPosition( 'first block', 6 ),
298
+ cursorEndPosition: createCursorPosition( 'second block', 3 ),
299
+ };
300
+
301
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
302
+ false
303
+ );
304
+ } );
305
+
306
+ test( 'returns false when end cursor position differs', () => {
307
+ const selection1: SelectionInMultipleBlocks = {
308
+ type: SelectionType.SelectionInMultipleBlocks,
309
+ cursorStartPosition: createCursorPosition( 'first block', 5 ),
310
+ cursorEndPosition: createCursorPosition( 'second block', 3 ),
311
+ };
312
+ const selection2: SelectionInMultipleBlocks = {
313
+ type: SelectionType.SelectionInMultipleBlocks,
314
+ cursorStartPosition: createCursorPosition( 'first block', 5 ),
315
+ cursorEndPosition: createCursorPosition( 'second block', 4 ),
316
+ };
317
+
318
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
319
+ false
320
+ );
321
+ } );
322
+ } );
323
+
324
+ describe( 'SelectionType.WholeBlock', () => {
325
+ test( 'returns true when whole block selections are identical', () => {
326
+ const blockPosition = createBlockPosition( 0 );
327
+ const selection1: SelectionWholeBlock = {
328
+ type: SelectionType.WholeBlock,
329
+ blockPosition,
330
+ };
331
+ const selection2: SelectionWholeBlock = {
332
+ type: SelectionType.WholeBlock,
333
+ blockPosition,
334
+ };
335
+
336
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
337
+ true
338
+ );
339
+ } );
340
+
341
+ test( 'returns false when blockPosition differs', () => {
342
+ const selection1: SelectionWholeBlock = {
343
+ type: SelectionType.WholeBlock,
344
+ blockPosition: createBlockPosition( 0 ),
345
+ };
346
+ const selection2: SelectionWholeBlock = {
347
+ type: SelectionType.WholeBlock,
348
+ blockPosition: createBlockPosition( 1 ),
349
+ };
350
+
351
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
352
+ false
353
+ );
354
+ } );
355
+ } );
356
+
357
+ describe( 'unknown selection type', () => {
358
+ test( 'returns false for unknown selection type', () => {
359
+ const selection1: SelectionState = {
360
+ type: 'unknown-type' as SelectionType,
361
+ } as SelectionState;
362
+ const selection2: SelectionNone = { type: SelectionType.None };
363
+
364
+ expect( areSelectionsStatesEqual( selection1, selection2 ) ).toBe(
365
+ false
366
+ );
367
+ } );
368
+ } );
369
+ } );
370
+
371
+ /**
372
+ * Helper function to create a Y.Doc with blocks for testing getSelectionState.
373
+ */
374
+ function createTestDocWithBlocks() {
375
+ const testDoc = new Y.Doc();
376
+ const documentMap = testDoc.getMap( CRDT_RECORD_MAP_KEY );
377
+ const blocks = new Y.Array();
378
+ documentMap.set( 'blocks', blocks );
379
+
380
+ // Create block 1 with a content attribute
381
+ const block1 = new Y.Map();
382
+ block1.set( 'clientId', 'block-1' );
383
+ const block1Attrs = new Y.Map();
384
+ block1Attrs.set( 'content', new Y.Text( 'Hello world' ) );
385
+ block1.set( 'attributes', block1Attrs );
386
+ block1.set( 'innerBlocks', new Y.Array() );
387
+ blocks.push( [ block1 ] );
388
+
389
+ // Create block 2 with a content attribute
390
+ const block2 = new Y.Map();
391
+ block2.set( 'clientId', 'block-2' );
392
+ const block2Attrs = new Y.Map();
393
+ block2Attrs.set( 'content', new Y.Text( 'Second block content' ) );
394
+ block2.set( 'attributes', block2Attrs );
395
+ block2.set( 'innerBlocks', new Y.Array() );
396
+ blocks.push( [ block2 ] );
397
+
398
+ // Create block 3 with nested inner blocks
399
+ const block3 = new Y.Map();
400
+ block3.set( 'clientId', 'block-3' );
401
+ const block3Attrs = new Y.Map();
402
+ block3Attrs.set( 'content', new Y.Text( 'Parent block' ) );
403
+ block3.set( 'attributes', block3Attrs );
404
+
405
+ // Add inner block to block 3
406
+ const innerBlocks = new Y.Array();
407
+ const innerBlock = new Y.Map();
408
+ innerBlock.set( 'clientId', 'inner-block-1' );
409
+ const innerBlockAttrs = new Y.Map();
410
+ innerBlockAttrs.set( 'content', new Y.Text( 'Inner block content' ) );
411
+ innerBlock.set( 'attributes', innerBlockAttrs );
412
+ innerBlock.set( 'innerBlocks', new Y.Array() );
413
+ innerBlocks.push( [ innerBlock ] );
414
+
415
+ block3.set( 'innerBlocks', innerBlocks );
416
+ blocks.push( [ block3 ] );
417
+
418
+ return testDoc;
419
+ }
420
+
421
+ describe( 'getSelectionState', () => {
422
+ let testDoc: Y.Doc;
423
+
424
+ const blockIndexMap: Record< string, number > = {
425
+ 'block-1': 0,
426
+ 'block-2': 1,
427
+ 'block-3': 2,
428
+ 'inner-block-1': 0,
429
+ };
430
+
431
+ const blockParentMap: Record< string, string > = {
432
+ 'block-1': '',
433
+ 'block-2': '',
434
+ 'block-3': '',
435
+ 'inner-block-1': 'block-3',
436
+ };
437
+
438
+ beforeEach( () => {
439
+ testDoc = createTestDocWithBlocks();
440
+
441
+ ( select as jest.Mock ).mockReturnValue( {
442
+ getBlockIndex: jest
443
+ .fn()
444
+ .mockImplementation(
445
+ ( clientId: string ) => blockIndexMap[ clientId ] ?? -1
446
+ ),
447
+ getBlockRootClientId: jest
448
+ .fn()
449
+ .mockImplementation(
450
+ ( clientId: string ) => blockParentMap[ clientId ] ?? ''
451
+ ),
452
+ getBlockName: jest.fn().mockReturnValue( 'core/paragraph' ),
453
+ } );
454
+ } );
455
+
456
+ afterEach( () => {
457
+ testDoc.destroy();
458
+ } );
459
+
460
+ describe( 'SelectionType.None', () => {
461
+ test( 'returns None when selectionStart is empty object', () => {
462
+ const selectionStart = {} as WPBlockSelection;
463
+ const selectionEnd = {} as WPBlockSelection;
464
+
465
+ const result = getSelectionState(
466
+ selectionStart,
467
+ selectionEnd,
468
+ testDoc
469
+ );
470
+
471
+ expect( result.type ).toBe( SelectionType.None );
472
+ } );
473
+ } );
474
+
475
+ describe( 'SelectionType.WholeBlock', () => {
476
+ test( 'returns WholeBlock when same clientId and no offsets', () => {
477
+ const selectionStart: WPBlockSelection = {
478
+ clientId: 'block-1',
479
+ attributeKey: 'content',
480
+ offset: undefined,
481
+ };
482
+ const selectionEnd: WPBlockSelection = {
483
+ clientId: 'block-1',
484
+ attributeKey: 'content',
485
+ offset: undefined,
486
+ };
487
+
488
+ const result = getSelectionState(
489
+ selectionStart,
490
+ selectionEnd,
491
+ testDoc
492
+ );
493
+
494
+ expect( result.type ).toBe( SelectionType.WholeBlock );
495
+ expect(
496
+ ( result as SelectionWholeBlock ).blockPosition
497
+ ).toBeDefined();
498
+ } );
499
+ } );
500
+
501
+ describe( 'SelectionType.Cursor', () => {
502
+ test( 'returns Cursor when same clientId and same offset', () => {
503
+ const selectionStart: WPBlockSelection = {
504
+ clientId: 'block-1',
505
+ attributeKey: 'content',
506
+ offset: 5,
507
+ };
508
+ const selectionEnd: WPBlockSelection = {
509
+ clientId: 'block-1',
510
+ attributeKey: 'content',
511
+ offset: 5,
512
+ };
513
+
514
+ const result = getSelectionState(
515
+ selectionStart,
516
+ selectionEnd,
517
+ testDoc
518
+ );
519
+
520
+ expect( result.type ).toBe( SelectionType.Cursor );
521
+ expect(
522
+ ( result as SelectionCursor ).cursorPosition
523
+ ).toBeDefined();
524
+ expect(
525
+ ( result as SelectionCursor ).cursorPosition.absoluteOffset
526
+ ).toBe( 5 );
527
+ } );
528
+
529
+ test( 'returns Cursor at start of block (offset 0)', () => {
530
+ const selectionStart: WPBlockSelection = {
531
+ clientId: 'block-1',
532
+ attributeKey: 'content',
533
+ offset: 0,
534
+ };
535
+ const selectionEnd: WPBlockSelection = {
536
+ clientId: 'block-1',
537
+ attributeKey: 'content',
538
+ offset: 0,
539
+ };
540
+
541
+ const result = getSelectionState(
542
+ selectionStart,
543
+ selectionEnd,
544
+ testDoc
545
+ );
546
+
547
+ expect( result.type ).toBe( SelectionType.Cursor );
548
+ expect(
549
+ ( result as SelectionCursor ).cursorPosition.absoluteOffset
550
+ ).toBe( 0 );
551
+ } );
552
+
553
+ test( 'returns None when block does not exist', () => {
554
+ const selectionStart: WPBlockSelection = {
555
+ clientId: 'non-existent-block',
556
+ attributeKey: 'content',
557
+ offset: 5,
558
+ };
559
+ const selectionEnd: WPBlockSelection = {
560
+ clientId: 'non-existent-block',
561
+ attributeKey: 'content',
562
+ offset: 5,
563
+ };
564
+
565
+ const result = getSelectionState(
566
+ selectionStart,
567
+ selectionEnd,
568
+ testDoc
569
+ );
570
+
571
+ expect( result.type ).toBe( SelectionType.None );
572
+ } );
573
+
574
+ test( 'returns None when attributeKey does not exist on block', () => {
575
+ const selectionStart: WPBlockSelection = {
576
+ clientId: 'block-1',
577
+ attributeKey: 'non-existent-attribute',
578
+ offset: 5,
579
+ };
580
+ const selectionEnd: WPBlockSelection = {
581
+ clientId: 'block-1',
582
+ attributeKey: 'non-existent-attribute',
583
+ offset: 5,
584
+ };
585
+
586
+ const result = getSelectionState(
587
+ selectionStart,
588
+ selectionEnd,
589
+ testDoc
590
+ );
591
+
592
+ expect( result.type ).toBe( SelectionType.None );
593
+ } );
594
+
595
+ test( 'returns None when attributeKey is missing', () => {
596
+ const selectionStart: WPBlockSelection = {
597
+ clientId: 'block-1',
598
+ attributeKey: undefined as unknown as string,
599
+ offset: 5,
600
+ };
601
+ const selectionEnd: WPBlockSelection = {
602
+ clientId: 'block-1',
603
+ attributeKey: undefined as unknown as string,
604
+ offset: 5,
605
+ };
606
+
607
+ const result = getSelectionState(
608
+ selectionStart,
609
+ selectionEnd,
610
+ testDoc
611
+ );
612
+
613
+ expect( result.type ).toBe( SelectionType.None );
614
+ } );
615
+ } );
616
+
617
+ describe( 'SelectionType.SelectionInOneBlock', () => {
618
+ test( 'returns SelectionInOneBlock when same clientId but different offsets', () => {
619
+ const selectionStart: WPBlockSelection = {
620
+ clientId: 'block-1',
621
+ attributeKey: 'content',
622
+ offset: 0,
623
+ };
624
+ const selectionEnd: WPBlockSelection = {
625
+ clientId: 'block-1',
626
+ attributeKey: 'content',
627
+ offset: 5,
628
+ };
629
+
630
+ const result = getSelectionState(
631
+ selectionStart,
632
+ selectionEnd,
633
+ testDoc
634
+ );
635
+
636
+ expect( result.type ).toBe( SelectionType.SelectionInOneBlock );
637
+ expect(
638
+ ( result as SelectionInOneBlock ).cursorStartPosition
639
+ .absoluteOffset
640
+ ).toBe( 0 );
641
+ expect(
642
+ ( result as SelectionInOneBlock ).cursorEndPosition
643
+ .absoluteOffset
644
+ ).toBe( 5 );
645
+ } );
646
+
647
+ test( 'returns None when start block does not exist', () => {
648
+ const selectionStart: WPBlockSelection = {
649
+ clientId: 'non-existent-block',
650
+ attributeKey: 'content',
651
+ offset: 0,
652
+ };
653
+ const selectionEnd: WPBlockSelection = {
654
+ clientId: 'non-existent-block',
655
+ attributeKey: 'content',
656
+ offset: 5,
657
+ };
658
+
659
+ const result = getSelectionState(
660
+ selectionStart,
661
+ selectionEnd,
662
+ testDoc
663
+ );
664
+
665
+ expect( result.type ).toBe( SelectionType.None );
666
+ } );
667
+ } );
668
+
669
+ describe( 'SelectionType.SelectionInMultipleBlocks', () => {
670
+ test( 'returns SelectionInMultipleBlocks when different clientIds', () => {
671
+ const selectionStart: WPBlockSelection = {
672
+ clientId: 'block-1',
673
+ attributeKey: 'content',
674
+ offset: 5,
675
+ };
676
+ const selectionEnd: WPBlockSelection = {
677
+ clientId: 'block-2',
678
+ attributeKey: 'content',
679
+ offset: 3,
680
+ };
681
+
682
+ const result = getSelectionState(
683
+ selectionStart,
684
+ selectionEnd,
685
+ testDoc
686
+ );
687
+
688
+ expect( result.type ).toBe(
689
+ SelectionType.SelectionInMultipleBlocks
690
+ );
691
+ expect(
692
+ ( result as SelectionInMultipleBlocks ).cursorStartPosition
693
+ .absoluteOffset
694
+ ).toBe( 5 );
695
+ expect(
696
+ ( result as SelectionInMultipleBlocks ).cursorEndPosition
697
+ .absoluteOffset
698
+ ).toBe( 3 );
699
+ } );
700
+
701
+ test( 'returns None when start block does not exist in multi-block selection', () => {
702
+ const selectionStart: WPBlockSelection = {
703
+ clientId: 'non-existent-block',
704
+ attributeKey: 'content',
705
+ offset: 5,
706
+ };
707
+ const selectionEnd: WPBlockSelection = {
708
+ clientId: 'block-2',
709
+ attributeKey: 'content',
710
+ offset: 3,
711
+ };
712
+
713
+ const result = getSelectionState(
714
+ selectionStart,
715
+ selectionEnd,
716
+ testDoc
717
+ );
718
+
719
+ expect( result.type ).toBe( SelectionType.None );
720
+ } );
721
+
722
+ test( 'returns None when end block does not exist in multi-block selection', () => {
723
+ const selectionStart: WPBlockSelection = {
724
+ clientId: 'block-1',
725
+ attributeKey: 'content',
726
+ offset: 5,
727
+ };
728
+ const selectionEnd: WPBlockSelection = {
729
+ clientId: 'non-existent-block',
730
+ attributeKey: 'content',
731
+ offset: 3,
732
+ };
733
+
734
+ const result = getSelectionState(
735
+ selectionStart,
736
+ selectionEnd,
737
+ testDoc
738
+ );
739
+
740
+ expect( result.type ).toBe( SelectionType.None );
741
+ } );
742
+ } );
743
+
744
+ describe( 'inner blocks', () => {
745
+ test( 'returns Cursor for selection in inner block', () => {
746
+ const selectionStart: WPBlockSelection = {
747
+ clientId: 'inner-block-1',
748
+ attributeKey: 'content',
749
+ offset: 3,
750
+ };
751
+ const selectionEnd: WPBlockSelection = {
752
+ clientId: 'inner-block-1',
753
+ attributeKey: 'content',
754
+ offset: 3,
755
+ };
756
+
757
+ const result = getSelectionState(
758
+ selectionStart,
759
+ selectionEnd,
760
+ testDoc
761
+ );
762
+
763
+ expect( result.type ).toBe( SelectionType.Cursor );
764
+ } );
765
+
766
+ test( 'returns SelectionInMultipleBlocks spanning parent and inner block', () => {
767
+ const selectionStart: WPBlockSelection = {
768
+ clientId: 'block-3',
769
+ attributeKey: 'content',
770
+ offset: 2,
771
+ };
772
+ const selectionEnd: WPBlockSelection = {
773
+ clientId: 'inner-block-1',
774
+ attributeKey: 'content',
775
+ offset: 5,
776
+ };
777
+
778
+ const result = getSelectionState(
779
+ selectionStart,
780
+ selectionEnd,
781
+ testDoc
782
+ );
783
+
784
+ expect( result.type ).toBe(
785
+ SelectionType.SelectionInMultipleBlocks
786
+ );
787
+ } );
788
+ } );
789
+
790
+ describe( 'relative position accuracy', () => {
791
+ test( 'cursor position survives text insertion before it', () => {
792
+ const selectionStart: WPBlockSelection = {
793
+ clientId: 'block-1',
794
+ attributeKey: 'content',
795
+ offset: 5,
796
+ };
797
+ const selectionEnd: WPBlockSelection = {
798
+ clientId: 'block-1',
799
+ attributeKey: 'content',
800
+ offset: 5,
801
+ };
802
+
803
+ const result = getSelectionState(
804
+ selectionStart,
805
+ selectionEnd,
806
+ testDoc
807
+ );
808
+
809
+ expect( result.type ).toBe( SelectionType.Cursor );
810
+ const cursorResult = result as SelectionCursor;
811
+
812
+ // Insert text before the cursor position
813
+ const documentMap = testDoc.getMap( CRDT_RECORD_MAP_KEY );
814
+ const blocks = documentMap.get( 'blocks' ) as Y.Array<
815
+ Y.Map< unknown >
816
+ >;
817
+ const block1 = blocks.get( 0 );
818
+ const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >;
819
+ const ytext = attrs.get( 'content' );
820
+
821
+ if ( ! ytext ) {
822
+ throw new Error( 'Y.Text not found' );
823
+ }
824
+
825
+ ytext.insert( 0, 'PREFIX ' ); // Insert at beginning
826
+
827
+ // Convert relative position back to absolute
828
+ const absolutePosition =
829
+ Y.createAbsolutePositionFromRelativePosition(
830
+ cursorResult.cursorPosition.relativePosition,
831
+ testDoc
832
+ );
833
+
834
+ // The absolute index should have shifted by 7 (length of 'PREFIX ')
835
+ expect( absolutePosition?.index ).toBe( 12 ); // 5 + 7
836
+ } );
837
+ } );
838
+
839
+ describe( 'edge cases', () => {
840
+ test( 'handles empty blocks array', () => {
841
+ const emptyDoc = new Y.Doc();
842
+ const documentMap = emptyDoc.getMap( CRDT_RECORD_MAP_KEY );
843
+ documentMap.set( 'blocks', new Y.Array() );
844
+
845
+ const selectionStart: WPBlockSelection = {
846
+ clientId: 'block-1',
847
+ attributeKey: 'content',
848
+ offset: 5,
849
+ };
850
+ const selectionEnd: WPBlockSelection = {
851
+ clientId: 'block-1',
852
+ attributeKey: 'content',
853
+ offset: 5,
854
+ };
855
+
856
+ const result = getSelectionState(
857
+ selectionStart,
858
+ selectionEnd,
859
+ emptyDoc
860
+ );
861
+
862
+ expect( result.type ).toBe( SelectionType.None );
863
+
864
+ emptyDoc.destroy();
865
+ } );
866
+
867
+ test( 'handles missing blocks in document', () => {
868
+ const docWithoutBlocks = new Y.Doc();
869
+ docWithoutBlocks.getMap( CRDT_RECORD_MAP_KEY );
870
+ // Note: not setting 'blocks' at all
871
+
872
+ const selectionStart: WPBlockSelection = {
873
+ clientId: 'block-1',
874
+ attributeKey: 'content',
875
+ offset: 5,
876
+ };
877
+ const selectionEnd: WPBlockSelection = {
878
+ clientId: 'block-1',
879
+ attributeKey: 'content',
880
+ offset: 5,
881
+ };
882
+
883
+ const result = getSelectionState(
884
+ selectionStart,
885
+ selectionEnd,
886
+ docWithoutBlocks
887
+ );
888
+
889
+ expect( result.type ).toBe( SelectionType.None );
890
+
891
+ docWithoutBlocks.destroy();
892
+ } );
893
+ } );
894
+ } );