@wordpress/core-data 7.38.0 → 7.39.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 (109) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/actions.cjs +17 -6
  3. package/build/actions.cjs.map +2 -2
  4. package/build/awareness/base-awareness.cjs +62 -0
  5. package/build/awareness/base-awareness.cjs.map +7 -0
  6. package/build/awareness/config.cjs +34 -0
  7. package/build/awareness/config.cjs.map +7 -0
  8. package/build/awareness/post-editor-awareness.cjs +130 -0
  9. package/build/awareness/post-editor-awareness.cjs.map +7 -0
  10. package/build/awareness/types.cjs +19 -0
  11. package/build/awareness/types.cjs.map +7 -0
  12. package/build/awareness/utils.cjs +116 -0
  13. package/build/awareness/utils.cjs.map +7 -0
  14. package/build/entities.cjs +27 -2
  15. package/build/entities.cjs.map +2 -2
  16. package/build/resolvers.cjs +43 -2
  17. package/build/resolvers.cjs.map +2 -2
  18. package/build/types.cjs.map +1 -1
  19. package/build/utils/block-selection-history.cjs +101 -0
  20. package/build/utils/block-selection-history.cjs.map +7 -0
  21. package/build/utils/crdt-selection.cjs +139 -0
  22. package/build/utils/crdt-selection.cjs.map +7 -0
  23. package/build/utils/crdt-user-selections.cjs +171 -0
  24. package/build/utils/crdt-user-selections.cjs.map +7 -0
  25. package/build/utils/crdt-utils.cjs +29 -0
  26. package/build/utils/crdt-utils.cjs.map +3 -3
  27. package/build/utils/crdt.cjs +16 -4
  28. package/build/utils/crdt.cjs.map +2 -2
  29. package/build-module/actions.mjs +17 -6
  30. package/build-module/actions.mjs.map +2 -2
  31. package/build-module/awareness/base-awareness.mjs +35 -0
  32. package/build-module/awareness/base-awareness.mjs.map +7 -0
  33. package/build-module/awareness/config.mjs +8 -0
  34. package/build-module/awareness/config.mjs.map +7 -0
  35. package/build-module/awareness/post-editor-awareness.mjs +111 -0
  36. package/build-module/awareness/post-editor-awareness.mjs.map +7 -0
  37. package/build-module/awareness/types.mjs +1 -0
  38. package/build-module/awareness/types.mjs.map +7 -0
  39. package/build-module/awareness/utils.mjs +90 -0
  40. package/build-module/awareness/utils.mjs.map +7 -0
  41. package/build-module/entities.mjs +28 -2
  42. package/build-module/entities.mjs.map +2 -2
  43. package/build-module/resolvers.mjs +43 -2
  44. package/build-module/resolvers.mjs.map +2 -2
  45. package/build-module/utils/block-selection-history.mjs +75 -0
  46. package/build-module/utils/block-selection-history.mjs.map +7 -0
  47. package/build-module/utils/crdt-selection.mjs +115 -0
  48. package/build-module/utils/crdt-selection.mjs.map +7 -0
  49. package/build-module/utils/crdt-user-selections.mjs +144 -0
  50. package/build-module/utils/crdt-user-selections.mjs.map +7 -0
  51. package/build-module/utils/crdt-utils.mjs +28 -0
  52. package/build-module/utils/crdt-utils.mjs.map +2 -2
  53. package/build-module/utils/crdt.mjs +18 -3
  54. package/build-module/utils/crdt.mjs.map +2 -2
  55. package/build-types/actions.d.ts.map +1 -1
  56. package/build-types/awareness/base-awareness.d.ts +19 -0
  57. package/build-types/awareness/base-awareness.d.ts.map +1 -0
  58. package/build-types/awareness/config.d.ts +9 -0
  59. package/build-types/awareness/config.d.ts.map +1 -0
  60. package/build-types/awareness/post-editor-awareness.d.ts +38 -0
  61. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -0
  62. package/build-types/awareness/types.d.ts +32 -0
  63. package/build-types/awareness/types.d.ts.map +1 -0
  64. package/build-types/awareness/utils.d.ts +22 -0
  65. package/build-types/awareness/utils.d.ts.map +1 -0
  66. package/build-types/entities.d.ts.map +1 -1
  67. package/build-types/entity-types/test/attachment.test.d.ts +10 -0
  68. package/build-types/entity-types/test/attachment.test.d.ts.map +1 -0
  69. package/build-types/index.d.ts.map +1 -1
  70. package/build-types/resolvers.d.ts.map +1 -1
  71. package/build-types/types.d.ts +12 -2
  72. package/build-types/types.d.ts.map +1 -1
  73. package/build-types/utils/block-selection-history.d.ts +47 -0
  74. package/build-types/utils/block-selection-history.d.ts.map +1 -0
  75. package/build-types/utils/crdt-selection.d.ts +16 -0
  76. package/build-types/utils/crdt-selection.d.ts.map +1 -0
  77. package/build-types/utils/crdt-user-selections.d.ts +66 -0
  78. package/build-types/utils/crdt-user-selections.d.ts.map +1 -0
  79. package/build-types/utils/crdt-utils.d.ts +12 -0
  80. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  81. package/build-types/utils/crdt.d.ts +8 -14
  82. package/build-types/utils/crdt.d.ts.map +1 -1
  83. package/build-types/utils/test/block-selection-history.test.d.ts +2 -0
  84. package/build-types/utils/test/block-selection-history.test.d.ts.map +1 -0
  85. package/build-types/utils/test/crdt-blocks.d.ts +2 -0
  86. package/build-types/utils/test/crdt-blocks.d.ts.map +1 -0
  87. package/build-types/utils/test/crdt.d.ts +2 -0
  88. package/build-types/utils/test/crdt.d.ts.map +1 -0
  89. package/package.json +21 -18
  90. package/src/actions.js +40 -7
  91. package/src/awareness/base-awareness.ts +50 -0
  92. package/src/awareness/config.ts +9 -0
  93. package/src/awareness/post-editor-awareness.ts +167 -0
  94. package/src/awareness/types.ts +38 -0
  95. package/src/awareness/utils.ts +159 -0
  96. package/src/entities.js +32 -2
  97. package/src/entity-types/test/attachment.test.ts +4 -4
  98. package/src/resolvers.js +53 -1
  99. package/src/test/actions.js +402 -0
  100. package/src/test/entity-provider.js +2 -0
  101. package/src/test/resolvers.js +4 -0
  102. package/src/types.ts +12 -3
  103. package/src/utils/block-selection-history.ts +176 -0
  104. package/src/utils/crdt-selection.ts +205 -0
  105. package/src/utils/crdt-user-selections.ts +336 -0
  106. package/src/utils/crdt-utils.ts +54 -0
  107. package/src/utils/crdt.ts +36 -3
  108. package/src/utils/test/block-selection-history.test.ts +764 -0
  109. package/src/utils/test/crdt-blocks.ts +11 -4
@@ -0,0 +1,764 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { Y } from '@wordpress/sync';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import {
10
+ createBlockSelectionHistory,
11
+ YSelectionType,
12
+ type YRelativeSelection,
13
+ type BlockSelectionHistory,
14
+ } from '../block-selection-history';
15
+ import { CRDT_RECORD_MAP_KEY } from '../../sync';
16
+ import type { WPSelection } from '../../types';
17
+
18
+ /**
19
+ * Helper function to create a simple Y.Doc with blocks for testing
20
+ */
21
+ function createTestDoc() {
22
+ const ydoc = new Y.Doc();
23
+ const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
24
+ const blocks = new Y.Array();
25
+ documentMap.set( 'blocks', blocks );
26
+
27
+ // Create block 1 with a content attribute
28
+ const block1 = new Y.Map();
29
+ block1.set( 'clientId', 'block-1' );
30
+ const block1Attrs = new Y.Map();
31
+ block1Attrs.set( 'content', new Y.Text( 'Hello world' ) );
32
+ block1.set( 'attributes', block1Attrs );
33
+ block1.set( 'innerBlocks', new Y.Array() );
34
+ blocks.push( [ block1 ] );
35
+
36
+ // Create block 2 with a content attribute
37
+ const block2 = new Y.Map();
38
+ block2.set( 'clientId', 'block-2' );
39
+ const block2Attrs = new Y.Map();
40
+ block2Attrs.set( 'content', new Y.Text( 'Second block' ) );
41
+ block2.set( 'attributes', block2Attrs );
42
+ block2.set( 'innerBlocks', new Y.Array() );
43
+ blocks.push( [ block2 ] );
44
+
45
+ // Create block 3 with a different attribute key
46
+ const block3 = new Y.Map();
47
+ block3.set( 'clientId', 'block-3' );
48
+ const block3Attrs = new Y.Map();
49
+ block3Attrs.set( 'value', new Y.Text( 'Third block with value attr' ) );
50
+ block3.set( 'attributes', block3Attrs );
51
+ block3.set( 'innerBlocks', new Y.Array() );
52
+ blocks.push( [ block3 ] );
53
+
54
+ return ydoc;
55
+ }
56
+
57
+ function createSelection(
58
+ start: { clientId: string; attributeKey?: string; offset?: number },
59
+ end?: { clientId: string; attributeKey?: string; offset?: number }
60
+ ): WPSelection {
61
+ const selectionStart = {
62
+ clientId: start.clientId,
63
+ attributeKey: start.attributeKey as string,
64
+ offset: start.offset ?? 0,
65
+ };
66
+
67
+ const selectionEnd = end
68
+ ? {
69
+ clientId: end.clientId,
70
+ attributeKey: end.attributeKey as string,
71
+ offset: end.offset ?? 0,
72
+ }
73
+ : selectionStart;
74
+
75
+ return {
76
+ selectionStart,
77
+ selectionEnd,
78
+ };
79
+ }
80
+
81
+ describe( 'BlockSelectionHistory', () => {
82
+ let history: BlockSelectionHistory;
83
+ let ydoc: Y.Doc;
84
+
85
+ beforeEach( () => {
86
+ ydoc = createTestDoc();
87
+ history = createBlockSelectionHistory( ydoc, 5 );
88
+ } );
89
+
90
+ afterEach( () => {
91
+ jest.restoreAllMocks();
92
+ } );
93
+
94
+ describe( 'initialization', () => {
95
+ test( 'should initialize with empty history', () => {
96
+ expect( history.getSelectionHistory() ).toEqual( [] );
97
+ } );
98
+ } );
99
+
100
+ describe( 'updateSelection with relative positions', () => {
101
+ test( 'should convert and store a selection as a relative position', () => {
102
+ const selection = createSelection( {
103
+ clientId: 'block-1',
104
+ attributeKey: 'content',
105
+ offset: 5,
106
+ } );
107
+ history.updateSelection( selection );
108
+
109
+ const selectionHistory = history.getSelectionHistory();
110
+ expect( selectionHistory.length ).toBe( 1 );
111
+
112
+ const fullSelection = selectionHistory[ 0 ];
113
+ expect( fullSelection.start.type ).toBe(
114
+ YSelectionType.RelativeSelection
115
+ );
116
+ expect( fullSelection.end.type ).toBe(
117
+ YSelectionType.RelativeSelection
118
+ );
119
+
120
+ const startPosition = fullSelection.start as YRelativeSelection;
121
+ const endPosition = fullSelection.end as YRelativeSelection;
122
+
123
+ expect( startPosition.clientId ).toBe( 'block-1' );
124
+ expect( startPosition.attributeKey ).toBe( 'content' );
125
+ expect( startPosition.offset ).toBe( 5 );
126
+ expect( startPosition.relativePosition ).toBeDefined();
127
+
128
+ expect( endPosition.clientId ).toBe( 'block-1' );
129
+ expect( endPosition.attributeKey ).toBe( 'content' );
130
+ expect( endPosition.offset ).toBe( 5 );
131
+ expect( endPosition.relativePosition ).toBeDefined();
132
+ } );
133
+
134
+ test( 'should update position when selection changes within same block', () => {
135
+ const selection1 = createSelection( {
136
+ clientId: 'block-1',
137
+ attributeKey: 'content',
138
+ offset: 5,
139
+ } );
140
+ history.updateSelection( selection1 );
141
+
142
+ const selection2 = createSelection( {
143
+ clientId: 'block-1',
144
+ attributeKey: 'content',
145
+ offset: 8,
146
+ } );
147
+ history.updateSelection( selection2 );
148
+
149
+ const selectionHistory = history.getSelectionHistory();
150
+
151
+ // Should have only one selection in block history (still in same block)
152
+ expect( selectionHistory.length ).toBe( 1 );
153
+
154
+ const fullSelection = selectionHistory[ 0 ];
155
+ expect( fullSelection.start.clientId ).toBe( 'block-1' );
156
+ expect( fullSelection.start.type ).toBe(
157
+ YSelectionType.RelativeSelection
158
+ );
159
+
160
+ const startPosition = fullSelection.start as YRelativeSelection;
161
+ expect( startPosition.offset ).toBe( 8 );
162
+ } );
163
+
164
+ test( 'should add new position when moving to different block', () => {
165
+ const selection1 = createSelection( {
166
+ clientId: 'block-1',
167
+ attributeKey: 'content',
168
+ offset: 5,
169
+ } );
170
+ history.updateSelection( selection1 );
171
+
172
+ const selection2 = createSelection( {
173
+ clientId: 'block-2',
174
+ attributeKey: 'content',
175
+ offset: 3,
176
+ } );
177
+ history.updateSelection( selection2 );
178
+
179
+ const selectionHistory = history.getSelectionHistory();
180
+ expect( selectionHistory.length ).toBe( 2 );
181
+
182
+ // Current selection at index 0
183
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-2' );
184
+ expect( selectionHistory[ 0 ].end.clientId ).toBe( 'block-2' );
185
+ // Previous selection at index 1
186
+ expect( selectionHistory[ 1 ].start.clientId ).toBe( 'block-1' );
187
+ expect( selectionHistory[ 1 ].end.clientId ).toBe( 'block-1' );
188
+ } );
189
+
190
+ test( 'should store offset 0 when offset is not provided', () => {
191
+ const selection = createSelection( {
192
+ clientId: 'block-1',
193
+ attributeKey: 'content',
194
+ } );
195
+ history.updateSelection( selection );
196
+
197
+ const selectionHistory = history.getSelectionHistory();
198
+ expect( selectionHistory.length ).toBe( 1 );
199
+
200
+ const fullSelection = selectionHistory[ 0 ];
201
+ expect( fullSelection.start.type ).toBe(
202
+ YSelectionType.RelativeSelection
203
+ );
204
+ const startPosition = fullSelection.start as YRelativeSelection;
205
+ expect( startPosition.offset ).toBe( 0 );
206
+
207
+ expect( fullSelection.end.type ).toBe(
208
+ YSelectionType.RelativeSelection
209
+ );
210
+ const endPosition = fullSelection.end as YRelativeSelection;
211
+ expect( endPosition.offset ).toBe( 0 );
212
+ } );
213
+ } );
214
+
215
+ describe( 'updateSelection with block positions', () => {
216
+ test( 'should create block position (not relative) when clientId does not exist', () => {
217
+ const selection = createSelection( {
218
+ clientId: 'non-existent-block',
219
+ attributeKey: 'content',
220
+ offset: 5,
221
+ } );
222
+ history.updateSelection( selection );
223
+
224
+ const selectionHistory = history.getSelectionHistory();
225
+ expect( selectionHistory.length ).toBe( 1 );
226
+
227
+ const fullSelection = selectionHistory[ 0 ];
228
+ expect( fullSelection.start.type ).toBe(
229
+ YSelectionType.BlockSelection
230
+ );
231
+ expect( fullSelection.start.clientId ).toBe( 'non-existent-block' );
232
+ expect( fullSelection.end.type ).toBe(
233
+ YSelectionType.BlockSelection
234
+ );
235
+ expect( fullSelection.end.clientId ).toBe( 'non-existent-block' );
236
+ } );
237
+
238
+ test( 'should create block position (not relative) when attribute does not exist', () => {
239
+ const selection = createSelection( {
240
+ clientId: 'block-1',
241
+ attributeKey: 'nonexistent',
242
+ offset: 5,
243
+ } );
244
+ history.updateSelection( selection );
245
+
246
+ const selectionHistory = history.getSelectionHistory();
247
+ expect( selectionHistory.length ).toBe( 1 );
248
+
249
+ const fullSelection = selectionHistory[ 0 ];
250
+ expect( fullSelection.start.type ).toBe(
251
+ YSelectionType.BlockSelection
252
+ );
253
+ expect( fullSelection.start.clientId ).toBe( 'block-1' );
254
+ expect( fullSelection.end.type ).toBe(
255
+ YSelectionType.BlockSelection
256
+ );
257
+ expect( fullSelection.end.clientId ).toBe( 'block-1' );
258
+ } );
259
+ } );
260
+
261
+ describe( 'updateSelection edge cases', () => {
262
+ test( 'should ignore null selection', () => {
263
+ history.updateSelection( null as any );
264
+ expect( history.getSelectionHistory() ).toEqual( [] );
265
+ } );
266
+
267
+ test( 'should ignore undefined selection', () => {
268
+ history.updateSelection( undefined as any );
269
+ expect( history.getSelectionHistory() ).toEqual( [] );
270
+ } );
271
+
272
+ test( 'should ignore selection without clientId', () => {
273
+ const invalidSelection = {
274
+ selectionStart: {
275
+ clientId: '',
276
+ attributeKey: 'content',
277
+ offset: 0,
278
+ },
279
+ selectionEnd: {
280
+ clientId: '',
281
+ attributeKey: 'content',
282
+ offset: 0,
283
+ },
284
+ };
285
+ history.updateSelection( invalidSelection );
286
+ expect( history.getSelectionHistory() ).toEqual( [] );
287
+ } );
288
+ } );
289
+
290
+ describe( 'history management', () => {
291
+ test( 'should maintain history of last N unique blocks', () => {
292
+ const selections = [
293
+ createSelection( {
294
+ clientId: 'block-1',
295
+ attributeKey: 'content',
296
+ offset: 1,
297
+ } ),
298
+ createSelection( {
299
+ clientId: 'block-2',
300
+ attributeKey: 'content',
301
+ offset: 2,
302
+ } ),
303
+ createSelection( {
304
+ clientId: 'block-3',
305
+ attributeKey: 'value',
306
+ offset: 3,
307
+ } ),
308
+ ];
309
+
310
+ selections.forEach( ( sel ) => history.updateSelection( sel ) );
311
+
312
+ const selectionHistory = history.getSelectionHistory();
313
+ expect( selectionHistory.length ).toBe( 3 );
314
+
315
+ // Current selection at index 0
316
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-3' );
317
+ expect( selectionHistory[ 0 ].end.clientId ).toBe( 'block-3' );
318
+ // Previous selections
319
+ expect( selectionHistory[ 1 ].start.clientId ).toBe( 'block-2' );
320
+ expect( selectionHistory[ 1 ].end.clientId ).toBe( 'block-2' );
321
+ expect( selectionHistory[ 2 ].start.clientId ).toBe( 'block-1' );
322
+ expect( selectionHistory[ 2 ].end.clientId ).toBe( 'block-1' );
323
+ } );
324
+
325
+ test( 'should respect history size limit', () => {
326
+ const smallHistory = createBlockSelectionHistory( ydoc, 3 );
327
+
328
+ // Add more selections than history size
329
+ for ( let i = 1; i <= 5; i++ ) {
330
+ const selection = createSelection( {
331
+ clientId: `block-${ i }`,
332
+ attributeKey: 'content',
333
+ offset: i,
334
+ } );
335
+ smallHistory.updateSelection( selection );
336
+ }
337
+
338
+ // Should only keep last 4 total (3 history + 1 current)
339
+ const selectionHistory = smallHistory.getSelectionHistory();
340
+ expect( selectionHistory.length ).toBe( 4 );
341
+
342
+ // Current selection at index 0
343
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-5' );
344
+ expect( selectionHistory[ 0 ].end.clientId ).toBe( 'block-5' );
345
+ // Previous 3 selections
346
+ expect( selectionHistory[ 1 ].start.clientId ).toBe( 'block-4' );
347
+ expect( selectionHistory[ 2 ].start.clientId ).toBe( 'block-3' );
348
+ expect( selectionHistory[ 3 ].start.clientId ).toBe( 'block-2' );
349
+ } );
350
+
351
+ test( 'should remove duplicate block from history when revisited', () => {
352
+ history.updateSelection(
353
+ createSelection( {
354
+ clientId: 'block-1',
355
+ attributeKey: 'content',
356
+ offset: 1,
357
+ } )
358
+ );
359
+ history.updateSelection(
360
+ createSelection( {
361
+ clientId: 'block-2',
362
+ attributeKey: 'content',
363
+ offset: 2,
364
+ } )
365
+ );
366
+ history.updateSelection(
367
+ createSelection( {
368
+ clientId: 'block-3',
369
+ attributeKey: 'value',
370
+ offset: 3,
371
+ } )
372
+ );
373
+
374
+ // Go back to block-1
375
+ history.updateSelection(
376
+ createSelection( {
377
+ clientId: 'block-1',
378
+ attributeKey: 'content',
379
+ offset: 5,
380
+ } )
381
+ );
382
+
383
+ const selectionHistory = history.getSelectionHistory();
384
+
385
+ // block-1 should be current (most recent) at index 0
386
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-1' );
387
+ expect( selectionHistory[ 0 ].start.type ).toBe(
388
+ YSelectionType.RelativeSelection
389
+ );
390
+
391
+ const startPosition = selectionHistory[ 0 ]
392
+ .start as YRelativeSelection;
393
+ expect( startPosition.offset ).toBe( 5 ); // Updated offset
394
+
395
+ // block-1 should not appear again in history
396
+ expect( selectionHistory.length ).toBe( 3 );
397
+ expect( selectionHistory[ 1 ].start.clientId ).toBe( 'block-3' );
398
+ expect( selectionHistory[ 2 ].start.clientId ).toBe( 'block-2' );
399
+ } );
400
+ } );
401
+
402
+ describe( 'getSelectionHistory', () => {
403
+ test( 'should return current selection when only one selection exists', () => {
404
+ history.updateSelection(
405
+ createSelection( {
406
+ clientId: 'block-1',
407
+ attributeKey: 'content',
408
+ offset: 1,
409
+ } )
410
+ );
411
+ const selectionHistory = history.getSelectionHistory();
412
+ expect( selectionHistory.length ).toBe( 1 );
413
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-1' );
414
+ } );
415
+
416
+ test( 'should return most recent position at index 0', () => {
417
+ history.updateSelection(
418
+ createSelection( {
419
+ clientId: 'block-1',
420
+ attributeKey: 'content',
421
+ offset: 1,
422
+ } )
423
+ );
424
+ history.updateSelection(
425
+ createSelection( {
426
+ clientId: 'block-2',
427
+ attributeKey: 'content',
428
+ offset: 2,
429
+ } )
430
+ );
431
+
432
+ const selectionHistory = history.getSelectionHistory();
433
+ expect( selectionHistory.length ).toBe( 2 );
434
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-2' );
435
+ expect( selectionHistory[ 0 ].end.clientId ).toBe( 'block-2' );
436
+ } );
437
+
438
+ test( 'should return empty array when history is empty', () => {
439
+ expect( history.getSelectionHistory() ).toEqual( [] );
440
+ } );
441
+ } );
442
+
443
+ describe( 'relative position accuracy', () => {
444
+ test( 'should create relative position that survives text insertion before it', () => {
445
+ const selection = createSelection( {
446
+ clientId: 'block-1',
447
+ attributeKey: 'content',
448
+ offset: 5,
449
+ } );
450
+ history.updateSelection( selection );
451
+
452
+ const selectionHistory = history.getSelectionHistory();
453
+ expect( selectionHistory.length ).toBe( 1 );
454
+
455
+ const fullSelection = selectionHistory[ 0 ];
456
+ expect( fullSelection.start.type ).toBe(
457
+ YSelectionType.RelativeSelection
458
+ );
459
+ const startPosition = fullSelection.start as YRelativeSelection;
460
+ // Get the Y.Text and insert text before the position
461
+ const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
462
+ const blocks = documentMap.get( 'blocks' ) as Y.Array< any >;
463
+ const block1 = blocks.get( 0 ) as Y.Map< any >;
464
+ const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >;
465
+ const ytext = attrs.get( 'content' );
466
+ if ( ! ytext ) {
467
+ throw new Error( 'Y.Text not found' );
468
+ }
469
+ ytext.insert( 0, 'PREFIX ' ); // Insert at beginning
470
+
471
+ // Convert relative position back to absolute
472
+ const absolutePosition =
473
+ Y.createAbsolutePositionFromRelativePosition(
474
+ startPosition.relativePosition,
475
+ ydoc
476
+ );
477
+
478
+ // The absolute index should have shifted by 7 (length of 'PREFIX ')
479
+ expect( absolutePosition?.index ).toBe( 12 ); // 5 + 7
480
+ } );
481
+
482
+ test( 'should create relative position that survives text deletion before it', () => {
483
+ const selection = createSelection( {
484
+ clientId: 'block-1',
485
+ attributeKey: 'content',
486
+ offset: 8,
487
+ } );
488
+ history.updateSelection( selection );
489
+
490
+ const selectionHistory = history.getSelectionHistory();
491
+ expect( selectionHistory.length ).toBe( 1 );
492
+
493
+ const fullSelection = selectionHistory[ 0 ];
494
+ expect( fullSelection.start.type ).toBe(
495
+ YSelectionType.RelativeSelection
496
+ );
497
+ const startPosition = fullSelection.start as YRelativeSelection;
498
+ // Delete text before the position
499
+ const documentMap = ydoc.getMap( CRDT_RECORD_MAP_KEY );
500
+ const blocks = documentMap.get( 'blocks' ) as Y.Array< any >;
501
+ const block1 = blocks.get( 0 ) as Y.Map< any >;
502
+ const attrs = block1.get( 'attributes' ) as Y.Map< Y.Text >;
503
+ const ytext = attrs.get( 'content' );
504
+ if ( ! ytext ) {
505
+ throw new Error( 'Y.Text not found' );
506
+ }
507
+ ytext.delete( 0, 5 ); // Delete "Hello"
508
+
509
+ // Convert relative position back to absolute
510
+ const absolutePosition =
511
+ Y.createAbsolutePositionFromRelativePosition(
512
+ startPosition.relativePosition,
513
+ ydoc
514
+ );
515
+
516
+ // The absolute index should have shifted back by 5
517
+ expect( absolutePosition?.index ).toBe( 3 ); // 8 - 5
518
+ } );
519
+ } );
520
+
521
+ describe( 'integration scenarios', () => {
522
+ test( 'should handle rapid selection changes in same block', () => {
523
+ for ( let i = 0; i < 10; i++ ) {
524
+ history.updateSelection(
525
+ createSelection( {
526
+ clientId: 'block-1',
527
+ attributeKey: 'content',
528
+ offset: i,
529
+ } )
530
+ );
531
+ }
532
+
533
+ const selectionHistory = history.getSelectionHistory();
534
+
535
+ // Should have only one selection in block history (still in same block)
536
+ expect( selectionHistory.length ).toBe( 1 );
537
+
538
+ const current = selectionHistory[ 0 ];
539
+ expect( current.start.clientId ).toBe( 'block-1' );
540
+ expect( current.start.type ).toBe(
541
+ YSelectionType.RelativeSelection
542
+ );
543
+ const startPosition = current.start as YRelativeSelection;
544
+
545
+ expect( startPosition.offset ).toBe( 9 );
546
+ } );
547
+
548
+ test( 'should handle alternating between two blocks', () => {
549
+ history.updateSelection(
550
+ createSelection( {
551
+ clientId: 'block-1',
552
+ attributeKey: 'content',
553
+ offset: 1,
554
+ } )
555
+ );
556
+ history.updateSelection(
557
+ createSelection( {
558
+ clientId: 'block-2',
559
+ attributeKey: 'content',
560
+ offset: 2,
561
+ } )
562
+ );
563
+ history.updateSelection(
564
+ createSelection( {
565
+ clientId: 'block-1',
566
+ attributeKey: 'content',
567
+ offset: 3,
568
+ } )
569
+ );
570
+ history.updateSelection(
571
+ createSelection( {
572
+ clientId: 'block-2',
573
+ attributeKey: 'content',
574
+ offset: 4,
575
+ } )
576
+ );
577
+
578
+ const selectionHistory = history.getSelectionHistory();
579
+
580
+ // Current selection at index 0
581
+ expect( selectionHistory[ 0 ].start.clientId ).toBe( 'block-2' );
582
+ expect( selectionHistory[ 0 ].end.clientId ).toBe( 'block-2' );
583
+ // Only one previous selection (block-1)
584
+ expect( selectionHistory[ 1 ].start.clientId ).toBe( 'block-1' );
585
+
586
+ // Should only have 2 entries total (current + 1 previous)
587
+ expect( selectionHistory.length ).toBe( 2 );
588
+ } );
589
+
590
+ test( 'should handle mixed block and relative selections', () => {
591
+ history.updateSelection(
592
+ createSelection( {
593
+ clientId: 'block-1',
594
+ attributeKey: 'content',
595
+ offset: 5,
596
+ } )
597
+ );
598
+ history.updateSelection(
599
+ createSelection( {
600
+ clientId: 'non-existent',
601
+ attributeKey: 'content',
602
+ offset: 0,
603
+ } )
604
+ );
605
+ history.updateSelection(
606
+ createSelection( {
607
+ clientId: 'block-2',
608
+ attributeKey: 'content',
609
+ offset: 3,
610
+ } )
611
+ );
612
+
613
+ const selectionHistory = history.getSelectionHistory();
614
+ const current = selectionHistory[ 0 ];
615
+ const backupSelections = selectionHistory.slice( 1 );
616
+
617
+ expect( current?.start.type ).toBe(
618
+ YSelectionType.RelativeSelection
619
+ );
620
+ expect( backupSelections[ 0 ]?.start.type ).toBe(
621
+ YSelectionType.BlockSelection
622
+ );
623
+ expect( backupSelections[ 1 ]?.start.type ).toBe(
624
+ YSelectionType.RelativeSelection
625
+ );
626
+ } );
627
+ } );
628
+
629
+ describe( 'cross-block selections', () => {
630
+ test( 'should update currentSelection when both start and end are within same block', () => {
631
+ const selection1 = createSelection(
632
+ { clientId: 'block-1', attributeKey: 'content', offset: 2 },
633
+ { clientId: 'block-1', attributeKey: 'content', offset: 8 }
634
+ );
635
+ history.updateSelection( selection1 );
636
+
637
+ const selection2 = createSelection(
638
+ { clientId: 'block-1', attributeKey: 'content', offset: 2 },
639
+ { clientId: 'block-1', attributeKey: 'content', offset: 10 }
640
+ );
641
+ history.updateSelection( selection2 );
642
+
643
+ const selectionHistory = history.getSelectionHistory();
644
+ const current = selectionHistory[ 0 ];
645
+
646
+ // Both selections are in same block, so should update current without adding to history
647
+ expect( current?.start.clientId ).toBe( 'block-1' );
648
+ expect( current?.end.clientId ).toBe( 'block-1' );
649
+ expect( ( current?.start as YRelativeSelection ).offset ).toBe( 2 );
650
+ expect( ( current?.end as YRelativeSelection ).offset ).toBe( 10 );
651
+ expect( selectionHistory.length ).toBe( 1 );
652
+ } );
653
+
654
+ test( 'should track cross-block selection spanning two blocks', () => {
655
+ const selection = createSelection(
656
+ { clientId: 'block-1', attributeKey: 'content', offset: 5 },
657
+ { clientId: 'block-2', attributeKey: 'content', offset: 3 }
658
+ );
659
+ history.updateSelection( selection );
660
+
661
+ const selectionHistory = history.getSelectionHistory();
662
+ const current = selectionHistory[ 0 ];
663
+
664
+ expect( current?.start.clientId ).toBe( 'block-1' );
665
+ expect( current?.start.type ).toBe(
666
+ YSelectionType.RelativeSelection
667
+ );
668
+ expect( ( current?.start as YRelativeSelection ).offset ).toBe( 5 );
669
+
670
+ expect( current?.end.clientId ).toBe( 'block-2' );
671
+ expect( current?.end.type ).toBe(
672
+ YSelectionType.RelativeSelection
673
+ );
674
+ expect( ( current?.end as YRelativeSelection ).offset ).toBe( 3 );
675
+ } );
676
+
677
+ test( 'should differentiate between same-block and cross-block selections', () => {
678
+ // Single block selection
679
+ history.updateSelection(
680
+ createSelection( {
681
+ clientId: 'block-1',
682
+ attributeKey: 'content',
683
+ offset: 5,
684
+ } )
685
+ );
686
+
687
+ // Cross-block selection from block-1 to block-2
688
+ history.updateSelection(
689
+ createSelection(
690
+ { clientId: 'block-1', attributeKey: 'content', offset: 3 },
691
+ { clientId: 'block-2', attributeKey: 'content', offset: 2 }
692
+ )
693
+ );
694
+
695
+ const selectionHistory = history.getSelectionHistory();
696
+ const current = selectionHistory[ 0 ];
697
+ const blockHistory = selectionHistory.slice( 1 );
698
+
699
+ // Should have moved to different block combination
700
+ expect( current?.start.clientId ).toBe( 'block-1' );
701
+ expect( current?.end.clientId ).toBe( 'block-2' );
702
+ expect( blockHistory.length ).toBe( 1 );
703
+ expect( blockHistory[ 0 ].start.clientId ).toBe( 'block-1' );
704
+ expect( blockHistory[ 0 ].end.clientId ).toBe( 'block-1' );
705
+ } );
706
+
707
+ test( 'should add to history when transitioning between different cross-block selections', () => {
708
+ // Cross-block from block-1 to block-2
709
+ history.updateSelection(
710
+ createSelection(
711
+ { clientId: 'block-1', attributeKey: 'content', offset: 5 },
712
+ { clientId: 'block-2', attributeKey: 'content', offset: 3 }
713
+ )
714
+ );
715
+
716
+ // Cross-block from block-2 to block-3
717
+ history.updateSelection(
718
+ createSelection(
719
+ { clientId: 'block-2', attributeKey: 'content', offset: 1 },
720
+ { clientId: 'block-3', attributeKey: 'value', offset: 2 }
721
+ )
722
+ );
723
+
724
+ const selectionHistory = history.getSelectionHistory();
725
+ const current = selectionHistory[ 0 ];
726
+ const blockHistory = selectionHistory.slice( 1 );
727
+
728
+ expect( current?.start.clientId ).toBe( 'block-2' );
729
+ expect( current?.end.clientId ).toBe( 'block-3' );
730
+ expect( blockHistory.length ).toBe( 1 );
731
+ expect( blockHistory[ 0 ].start.clientId ).toBe( 'block-1' );
732
+ expect( blockHistory[ 0 ].end.clientId ).toBe( 'block-2' );
733
+ } );
734
+
735
+ test( 'should not add to history when updating same cross-block selection combination', () => {
736
+ // Cross-block from block-1 to block-2
737
+ history.updateSelection(
738
+ createSelection(
739
+ { clientId: 'block-1', attributeKey: 'content', offset: 5 },
740
+ { clientId: 'block-2', attributeKey: 'content', offset: 3 }
741
+ )
742
+ );
743
+
744
+ // Same block combination, different offsets
745
+ history.updateSelection(
746
+ createSelection(
747
+ { clientId: 'block-1', attributeKey: 'content', offset: 2 },
748
+ { clientId: 'block-2', attributeKey: 'content', offset: 8 }
749
+ )
750
+ );
751
+
752
+ const selectionHistory = history.getSelectionHistory();
753
+ const current = selectionHistory[ 0 ];
754
+ const blockHistory = selectionHistory.slice( 1 );
755
+
756
+ expect( current?.start.clientId ).toBe( 'block-1' );
757
+ expect( current?.end.clientId ).toBe( 'block-2' );
758
+ expect( ( current?.start as YRelativeSelection ).offset ).toBe( 2 );
759
+ expect( ( current?.end as YRelativeSelection ).offset ).toBe( 8 );
760
+ // Should not add to history - same block combination
761
+ expect( blockHistory.length ).toBe( 0 );
762
+ } );
763
+ } );
764
+ } );