@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
@@ -18,6 +18,7 @@ import {
18
18
  receiveCurrentUser,
19
19
  __experimentalBatch,
20
20
  } from '../actions';
21
+ import { getSyncManager } from '../sync';
21
22
 
22
23
  jest.mock( '../batch', () => {
23
24
  const { createBatch } = jest.requireActual( '../batch' );
@@ -28,6 +29,11 @@ jest.mock( '../batch', () => {
28
29
  };
29
30
  } );
30
31
 
32
+ jest.mock( '../sync', () => ( {
33
+ getSyncManager: jest.fn(),
34
+ LOCAL_EDITOR_ORIGIN: 'local-editor',
35
+ } ) );
36
+
31
37
  describe( 'editEntityRecord', () => {
32
38
  it( 'throws when the edited entity does not have a loaded config.', async () => {
33
39
  const entityConfig = {
@@ -50,6 +56,402 @@ describe( 'editEntityRecord', () => {
50
56
  );
51
57
  expect( select.getEntityConfig ).toHaveBeenCalledTimes( 1 );
52
58
  } );
59
+
60
+ it( 'dispatches the correct action for non-merged edits', () => {
61
+ const dispatch = jest.fn();
62
+ const select = {
63
+ getEntityConfig: () => ( {
64
+ kind: 'postType',
65
+ name: 'post',
66
+ mergedEdits: {},
67
+ } ),
68
+ getRawEntityRecord: () => ( {
69
+ id: 1,
70
+ title: 'Original Title',
71
+ content: 'Original Content',
72
+ } ),
73
+ getEditedEntityRecord: () => ( {
74
+ id: 1,
75
+ title: 'Original Title',
76
+ content: 'Original Content',
77
+ } ),
78
+ getUndoManager: () => ( {
79
+ addRecord: jest.fn(),
80
+ } ),
81
+ };
82
+
83
+ editEntityRecord( 'postType', 'post', 1, { title: 'New Title' } )( {
84
+ select,
85
+ dispatch,
86
+ } );
87
+
88
+ expect( dispatch ).toHaveBeenCalledWith( {
89
+ type: 'EDIT_ENTITY_RECORD',
90
+ kind: 'postType',
91
+ name: 'post',
92
+ recordId: 1,
93
+ edits: { title: 'New Title' },
94
+ } );
95
+ } );
96
+
97
+ it( 'merges edits for fields defined in mergedEdits config', () => {
98
+ const dispatch = jest.fn();
99
+ const select = {
100
+ getEntityConfig: () => ( {
101
+ kind: 'postType',
102
+ name: 'post',
103
+ mergedEdits: { meta: true },
104
+ } ),
105
+ getRawEntityRecord: () => ( {
106
+ id: 1,
107
+ meta: { existingKey: 'existingValue' },
108
+ } ),
109
+ getEditedEntityRecord: () => ( {
110
+ id: 1,
111
+ meta: {
112
+ existingKey: 'existingValue',
113
+ editedKey: 'editedValue',
114
+ },
115
+ } ),
116
+ getUndoManager: () => ( {
117
+ addRecord: jest.fn(),
118
+ } ),
119
+ };
120
+
121
+ editEntityRecord( 'postType', 'post', 1, {
122
+ meta: { newKey: 'newValue' },
123
+ } )( {
124
+ select,
125
+ dispatch,
126
+ } );
127
+
128
+ expect( dispatch ).toHaveBeenCalledWith( {
129
+ type: 'EDIT_ENTITY_RECORD',
130
+ kind: 'postType',
131
+ name: 'post',
132
+ recordId: 1,
133
+ edits: {
134
+ meta: {
135
+ existingKey: 'existingValue',
136
+ editedKey: 'editedValue',
137
+ newKey: 'newValue',
138
+ },
139
+ },
140
+ } );
141
+ } );
142
+
143
+ it( 'handles both merged and non-merged edits together', () => {
144
+ const dispatch = jest.fn();
145
+ const select = {
146
+ getEntityConfig: () => ( {
147
+ kind: 'postType',
148
+ name: 'post',
149
+ mergedEdits: { meta: true },
150
+ } ),
151
+ getRawEntityRecord: () => ( {
152
+ id: 1,
153
+ title: 'Original Title',
154
+ meta: { existingKey: 'existingValue' },
155
+ } ),
156
+ getEditedEntityRecord: () => ( {
157
+ id: 1,
158
+ title: 'Original Title',
159
+ meta: { existingKey: 'existingValue' },
160
+ } ),
161
+ getUndoManager: () => ( {
162
+ addRecord: jest.fn(),
163
+ } ),
164
+ };
165
+
166
+ editEntityRecord( 'postType', 'post', 1, {
167
+ title: 'New Title',
168
+ meta: { newKey: 'newValue' },
169
+ } )( { select, dispatch } );
170
+
171
+ expect( dispatch ).toHaveBeenCalledWith( {
172
+ type: 'EDIT_ENTITY_RECORD',
173
+ kind: 'postType',
174
+ name: 'post',
175
+ recordId: 1,
176
+ edits: {
177
+ title: 'New Title',
178
+ meta: {
179
+ existingKey: 'existingValue',
180
+ newKey: 'newValue',
181
+ },
182
+ },
183
+ } );
184
+ } );
185
+
186
+ it( 'clears edit when merged value equals persisted record', () => {
187
+ const dispatch = jest.fn();
188
+ const select = {
189
+ getEntityConfig: () => ( {
190
+ kind: 'postType',
191
+ name: 'post',
192
+ mergedEdits: { meta: true },
193
+ } ),
194
+ getRawEntityRecord: () => ( {
195
+ id: 1,
196
+ meta: { key1: 'value1', key2: 'value2' },
197
+ } ),
198
+ getEditedEntityRecord: () => ( {
199
+ id: 1,
200
+ meta: { key1: 'value1' },
201
+ } ),
202
+ getUndoManager: () => ( {
203
+ addRecord: jest.fn(),
204
+ } ),
205
+ };
206
+
207
+ // Editing meta to add key2 back should result in a value equal to the persisted record
208
+ editEntityRecord( 'postType', 'post', 1, {
209
+ meta: { key2: 'value2' },
210
+ } )( { select, dispatch } );
211
+
212
+ expect( dispatch ).toHaveBeenCalledWith( {
213
+ type: 'EDIT_ENTITY_RECORD',
214
+ kind: 'postType',
215
+ name: 'post',
216
+ recordId: 1,
217
+ edits: {
218
+ // meta should be undefined because merged value equals persisted record
219
+ meta: undefined,
220
+ },
221
+ } );
222
+ } );
223
+
224
+ it( 'clears non-merged edit when value equals persisted record', () => {
225
+ const dispatch = jest.fn();
226
+ const select = {
227
+ getEntityConfig: () => ( {
228
+ kind: 'postType',
229
+ name: 'post',
230
+ mergedEdits: {},
231
+ } ),
232
+ getRawEntityRecord: () => ( {
233
+ id: 1,
234
+ title: 'Original Title',
235
+ } ),
236
+ getEditedEntityRecord: () => ( {
237
+ id: 1,
238
+ title: 'Edited Title',
239
+ } ),
240
+ getUndoManager: () => ( {
241
+ addRecord: jest.fn(),
242
+ } ),
243
+ };
244
+
245
+ // Editing title back to original should clear the edit
246
+ editEntityRecord( 'postType', 'post', 1, {
247
+ title: 'Original Title',
248
+ } )( { select, dispatch } );
249
+
250
+ expect( dispatch ).toHaveBeenCalledWith( {
251
+ type: 'EDIT_ENTITY_RECORD',
252
+ kind: 'postType',
253
+ name: 'post',
254
+ recordId: 1,
255
+ edits: {
256
+ title: undefined,
257
+ },
258
+ } );
259
+ } );
260
+
261
+ describe( 'with SyncManager', () => {
262
+ let syncManager;
263
+
264
+ beforeEach( () => {
265
+ // Create a mock sync manager
266
+ syncManager = {
267
+ update: jest.fn(),
268
+ };
269
+ getSyncManager.mockReturnValue( syncManager );
270
+ } );
271
+
272
+ afterEach( () => {
273
+ getSyncManager.mockReset();
274
+ } );
275
+
276
+ it( 'passes merged edits to SyncManager#update for merged fields', () => {
277
+ const dispatch = jest.fn();
278
+ const select = {
279
+ getEntityConfig: () => ( {
280
+ kind: 'postType',
281
+ name: 'post',
282
+ mergedEdits: { meta: true },
283
+ syncConfig: {},
284
+ } ),
285
+ getRawEntityRecord: () => ( {
286
+ id: 1,
287
+ meta: { existingKey: 'existingValue' },
288
+ } ),
289
+ getEditedEntityRecord: () => ( {
290
+ id: 1,
291
+ meta: {
292
+ existingKey: 'existingValue',
293
+ editedKey: 'editedValue',
294
+ },
295
+ } ),
296
+ getUndoManager: () => ( {
297
+ addRecord: jest.fn(),
298
+ } ),
299
+ };
300
+
301
+ editEntityRecord( 'postType', 'post', 1, {
302
+ meta: { newKey: 'newValue' },
303
+ } )( {
304
+ select,
305
+ dispatch,
306
+ } );
307
+
308
+ // Verify SyncManager#update was called with merged edits
309
+ expect( syncManager.update ).toHaveBeenCalledWith(
310
+ 'postType/post',
311
+ 1,
312
+ {
313
+ meta: {
314
+ existingKey: 'existingValue',
315
+ editedKey: 'editedValue',
316
+ newKey: 'newValue',
317
+ },
318
+ },
319
+ 'local-editor',
320
+ { isNewUndoLevel: true }
321
+ );
322
+ } );
323
+
324
+ it( 'passes merged edits to SyncManager#update even when value equals persisted record', () => {
325
+ const dispatch = jest.fn();
326
+ const select = {
327
+ getEntityConfig: () => ( {
328
+ kind: 'postType',
329
+ name: 'post',
330
+ mergedEdits: { meta: true },
331
+ syncConfig: {},
332
+ } ),
333
+ getRawEntityRecord: () => ( {
334
+ id: 1,
335
+ meta: { key1: 'value1', key2: 'value2' },
336
+ } ),
337
+ getEditedEntityRecord: () => ( {
338
+ id: 1,
339
+ meta: { key1: 'value1' },
340
+ } ),
341
+ getUndoManager: () => ( {
342
+ addRecord: jest.fn(),
343
+ } ),
344
+ };
345
+
346
+ // Editing meta to add key2 back results in a value equal to the persisted record
347
+ editEntityRecord( 'postType', 'post', 1, {
348
+ meta: { key2: 'value2' },
349
+ } )( { select, dispatch } );
350
+
351
+ // Verify SyncManager#update was called with merged edits (not cleaned/undefined)
352
+ expect( syncManager.update ).toHaveBeenCalledWith(
353
+ 'postType/post',
354
+ 1,
355
+ {
356
+ meta: {
357
+ key1: 'value1',
358
+ key2: 'value2',
359
+ },
360
+ },
361
+ 'local-editor',
362
+ { isNewUndoLevel: true }
363
+ );
364
+
365
+ // But the local store dispatch should still receive undefined for the cleaned edit
366
+ expect( dispatch ).toHaveBeenCalledWith( {
367
+ type: 'EDIT_ENTITY_RECORD',
368
+ kind: 'postType',
369
+ name: 'post',
370
+ recordId: 1,
371
+ edits: {
372
+ meta: undefined,
373
+ },
374
+ } );
375
+ } );
376
+
377
+ it( 'passes merged and non-merged edits correctly to SyncManager#update', () => {
378
+ const dispatch = jest.fn();
379
+ const select = {
380
+ getEntityConfig: () => ( {
381
+ kind: 'postType',
382
+ name: 'post',
383
+ mergedEdits: { meta: true },
384
+ syncConfig: {},
385
+ } ),
386
+ getRawEntityRecord: () => ( {
387
+ id: 1,
388
+ title: 'Original Title',
389
+ meta: { existingKey: 'existingValue' },
390
+ } ),
391
+ getEditedEntityRecord: () => ( {
392
+ id: 1,
393
+ title: 'Original Title',
394
+ meta: { existingKey: 'existingValue' },
395
+ } ),
396
+ getUndoManager: () => ( {
397
+ addRecord: jest.fn(),
398
+ } ),
399
+ };
400
+
401
+ editEntityRecord( 'postType', 'post', 1, {
402
+ title: 'New Title',
403
+ meta: { newKey: 'newValue' },
404
+ } )( { select, dispatch } );
405
+
406
+ // Verify SyncManager#update was called with merged meta but non-merged title
407
+ expect( syncManager.update ).toHaveBeenCalledWith(
408
+ 'postType/post',
409
+ 1,
410
+ {
411
+ title: 'New Title',
412
+ meta: {
413
+ existingKey: 'existingValue',
414
+ newKey: 'newValue',
415
+ },
416
+ },
417
+ 'local-editor',
418
+ { isNewUndoLevel: true }
419
+ );
420
+ } );
421
+
422
+ it( 'does not call SyncManager#update when syncConfig is not defined', () => {
423
+ const dispatch = jest.fn();
424
+ const select = {
425
+ getEntityConfig: () => ( {
426
+ kind: 'postType',
427
+ name: 'post',
428
+ mergedEdits: { meta: true },
429
+ // No syncConfig
430
+ } ),
431
+ getRawEntityRecord: () => ( {
432
+ id: 1,
433
+ meta: { existingKey: 'existingValue' },
434
+ } ),
435
+ getEditedEntityRecord: () => ( {
436
+ id: 1,
437
+ meta: { existingKey: 'existingValue' },
438
+ } ),
439
+ getUndoManager: () => ( {
440
+ addRecord: jest.fn(),
441
+ } ),
442
+ };
443
+
444
+ editEntityRecord( 'postType', 'post', 1, {
445
+ meta: { newKey: 'newValue' },
446
+ } )( {
447
+ select,
448
+ dispatch,
449
+ } );
450
+
451
+ // Verify SyncManager#update was NOT called
452
+ expect( syncManager.update ).not.toHaveBeenCalled();
453
+ } );
454
+ } );
53
455
  } );
54
456
 
55
457
  describe( 'deleteEntityRecord', () => {
@@ -86,6 +86,7 @@ describe( 'useEntityBlockEditor', () => {
86
86
  const edit = ( { children } ) => <>{ children }</>;
87
87
 
88
88
  registerBlockType( 'core/test-block', {
89
+ apiVersion: 3,
89
90
  supports: {
90
91
  className: false,
91
92
  },
@@ -112,6 +113,7 @@ describe( 'useEntityBlockEditor', () => {
112
113
  } );
113
114
 
114
115
  registerBlockType( 'core/test-block-with-array-of-strings', {
116
+ apiVersion: 3,
115
117
  supports: {
116
118
  className: false,
117
119
  },
@@ -168,9 +168,11 @@ describe( 'getEntityRecord', () => {
168
168
  1,
169
169
  POST_RECORD,
170
170
  {
171
+ addUndoMeta: expect.any( Function ),
171
172
  editRecord: expect.any( Function ),
172
173
  getEditedRecord: expect.any( Function ),
173
174
  refetchRecord: expect.any( Function ),
175
+ restoreUndoMeta: expect.any( Function ),
174
176
  saveRecord: expect.any( Function ),
175
177
  }
176
178
  );
@@ -221,9 +223,11 @@ describe( 'getEntityRecord', () => {
221
223
  1,
222
224
  { ...POST_RECORD, foo: 'bar' },
223
225
  {
226
+ addUndoMeta: expect.any( Function ),
224
227
  editRecord: expect.any( Function ),
225
228
  getEditedRecord: expect.any( Function ),
226
229
  refetchRecord: expect.any( Function ),
230
+ restoreUndoMeta: expect.any( Function ),
227
231
  saveRecord: expect.any( Function ),
228
232
  }
229
233
  );
package/src/types.ts CHANGED
@@ -2,11 +2,20 @@ export interface AnyFunction {
2
2
  ( ...args: any[] ): any;
3
3
  }
4
4
 
5
- // Avoid a circular dependency with @wordpress/editor
5
+ /**
6
+ * Avoid a circular dependency with @wordpress/editor
7
+ *
8
+ * Additionaly, this type marks `attributeKey` and `offset` as possibly
9
+ * `undefined`, which can happen in two known scenarios:
10
+ *
11
+ * 1. If a user has an entire block highlighted (e.g., a `core/image` block).
12
+ * 2. If there's an intermediate selection state while inserting a block, those
13
+ * properties will be temporarily`undefined`.
14
+ */
6
15
  export interface WPBlockSelection {
7
16
  clientId: string;
8
- attributeKey: string;
9
- offset: number;
17
+ attributeKey?: string;
18
+ offset?: number;
10
19
  }
11
20
 
12
21
  export interface WPSelection {
@@ -0,0 +1,176 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ /**
5
+ * WordPress dependencies
6
+ */
7
+ import { Y } from '@wordpress/sync';
8
+
9
+ /**
10
+ * Internal dependencies
11
+ */
12
+ import { findBlockByClientIdInDoc } from './crdt-utils';
13
+ import type { WPBlockSelection, WPSelection } from '../types';
14
+
15
+ // Default size for selection history (not including current selection)
16
+ const SELECTION_HISTORY_DEFAULT_SIZE = 5;
17
+
18
+ export enum YSelectionType {
19
+ RelativeSelection = 'RelativeSelection',
20
+ BlockSelection = 'BlockSelection',
21
+ }
22
+
23
+ export interface YRelativeSelection {
24
+ type: YSelectionType.RelativeSelection;
25
+ attributeKey: string;
26
+ relativePosition: Y.RelativePosition;
27
+ clientId: string;
28
+ offset: number;
29
+ }
30
+
31
+ export interface YBlockSelection {
32
+ type: YSelectionType.BlockSelection;
33
+ clientId: string;
34
+ }
35
+
36
+ export type YSelection = YRelativeSelection | YBlockSelection;
37
+
38
+ export type YFullSelection = {
39
+ start: YSelection;
40
+ end: YSelection;
41
+ };
42
+
43
+ export interface YSelectionHistory {
44
+ selection: YFullSelection;
45
+ backupSelections?: YFullSelection[];
46
+ }
47
+
48
+ export interface BlockSelectionHistory {
49
+ getSelectionHistory: () => YFullSelection[];
50
+ updateSelection: ( newSelection: WPSelection ) => void;
51
+ }
52
+
53
+ /**
54
+ * This function is used to track recent block selections to help in restoring
55
+ * a user's selection after an undo or redo operation.
56
+ *
57
+ * Maintains a history array for previous selections, which can be used for
58
+ * backup restoration locations.
59
+ * @param ydoc
60
+ * @param historySize
61
+ */
62
+ export function createBlockSelectionHistory(
63
+ ydoc: Y.Doc,
64
+ historySize: number = SELECTION_HISTORY_DEFAULT_SIZE
65
+ ): BlockSelectionHistory {
66
+ let history: YFullSelection[] = [];
67
+
68
+ /**
69
+ * Get the block history including current selection.
70
+ */
71
+ const getSelectionHistory = (): YFullSelection[] => {
72
+ return history.slice( 0 );
73
+ };
74
+
75
+ /**
76
+ * Update the selection history with a new selection.
77
+ * @param newSelection
78
+ */
79
+ const updateSelection = ( newSelection: WPSelection ): void => {
80
+ if (
81
+ ! newSelection?.selectionStart?.clientId ||
82
+ ! newSelection?.selectionEnd?.clientId
83
+ ) {
84
+ return;
85
+ }
86
+
87
+ const { selectionStart, selectionEnd } = newSelection;
88
+ const start = convertWPBlockSelectionToSelection(
89
+ selectionStart,
90
+ ydoc
91
+ );
92
+ const end = convertWPBlockSelectionToSelection( selectionEnd, ydoc );
93
+
94
+ addToHistory( { start, end } );
95
+ };
96
+
97
+ /**
98
+ * Add a selection to the history, maintaining only the last `historySize` unique selections.
99
+ * New selections are added to the front.
100
+ * Removes any existing entries with the same start and end block combination.
101
+ * @param yFullSelection
102
+ */
103
+ const addToHistory = ( yFullSelection: YFullSelection ): void => {
104
+ // Remove any existing entries with the same start and end block combination
105
+ const startClientId = yFullSelection.start.clientId;
106
+ const endClientId = yFullSelection.end.clientId;
107
+
108
+ history = history.filter( ( entry ) => {
109
+ const isSameBlockCombination =
110
+ entry.start.clientId === startClientId &&
111
+ entry.end.clientId === endClientId;
112
+
113
+ return ! isSameBlockCombination;
114
+ } );
115
+
116
+ // Add the new selection to the front
117
+ history.unshift( yFullSelection );
118
+
119
+ // Trim to max size (remove oldest entries from the back)
120
+ if ( history.length > historySize + 1 ) {
121
+ history = history.slice( 0, historySize + 1 );
122
+ }
123
+ };
124
+
125
+ return {
126
+ getSelectionHistory,
127
+ updateSelection,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Convert a WPBlockSelection to a YSelection.
133
+ * @param selection
134
+ * @param ydoc
135
+ * @return A YSelection object.
136
+ */
137
+ function convertWPBlockSelectionToSelection(
138
+ selection: WPBlockSelection,
139
+ ydoc: Y.Doc
140
+ ): YSelection {
141
+ const clientId = selection.clientId;
142
+ const block = findBlockByClientIdInDoc( clientId, ydoc );
143
+ const attributes = block?.get( 'attributes' );
144
+ const attributeKey = selection.attributeKey;
145
+
146
+ const changedYText = attributeKey
147
+ ? attributes?.get( attributeKey )
148
+ : undefined;
149
+
150
+ const isYText = changedYText instanceof Y.Text;
151
+ const isFullyDefinedSelection = attributeKey && clientId;
152
+
153
+ if ( ! isYText || ! isFullyDefinedSelection ) {
154
+ // We either don't have a valid YText (it's been deleted) or we've
155
+ // been passed a selection that's just a block clientId.
156
+ // Store as BlockSelection.
157
+ return {
158
+ type: YSelectionType.BlockSelection,
159
+ clientId,
160
+ };
161
+ }
162
+
163
+ const offset = selection.offset ?? 0;
164
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
165
+ changedYText,
166
+ offset
167
+ );
168
+
169
+ return {
170
+ type: YSelectionType.RelativeSelection,
171
+ attributeKey,
172
+ relativePosition,
173
+ clientId,
174
+ offset,
175
+ };
176
+ }