@wordpress/core-data 7.41.2-next.v.202603102151.0 → 7.42.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 (166) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/README.md +19 -0
  3. package/build/actions.cjs +17 -25
  4. package/build/actions.cjs.map +2 -2
  5. package/build/awareness/post-editor-awareness.cjs +46 -6
  6. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  7. package/build/awareness/types.cjs.map +1 -1
  8. package/build/entities.cjs +33 -7
  9. package/build/entities.cjs.map +2 -2
  10. package/build/hooks/use-entity-prop.cjs +2 -1
  11. package/build/hooks/use-entity-prop.cjs.map +2 -2
  12. package/build/hooks/use-post-editor-awareness-state.cjs +84 -3
  13. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  14. package/build/index.cjs +3 -0
  15. package/build/index.cjs.map +2 -2
  16. package/build/private-apis.cjs +3 -1
  17. package/build/private-apis.cjs.map +2 -2
  18. package/build/queried-data/get-query-parts.cjs +7 -0
  19. package/build/queried-data/get-query-parts.cjs.map +2 -2
  20. package/build/queried-data/selectors.cjs +19 -5
  21. package/build/queried-data/selectors.cjs.map +2 -2
  22. package/build/reducer.cjs +6 -0
  23. package/build/reducer.cjs.map +2 -2
  24. package/build/resolvers.cjs +110 -74
  25. package/build/resolvers.cjs.map +2 -2
  26. package/build/selectors.cjs +29 -0
  27. package/build/selectors.cjs.map +2 -2
  28. package/build/sync.cjs +3 -0
  29. package/build/sync.cjs.map +2 -2
  30. package/build/types.cjs +16 -0
  31. package/build/types.cjs.map +3 -3
  32. package/build/utils/block-selection-history.cjs +1 -1
  33. package/build/utils/block-selection-history.cjs.map +2 -2
  34. package/build/utils/crdt-blocks.cjs +17 -3
  35. package/build/utils/crdt-blocks.cjs.map +2 -2
  36. package/build/utils/crdt-selection.cjs +4 -1
  37. package/build/utils/crdt-selection.cjs.map +2 -2
  38. package/build/utils/crdt-user-selections.cjs +9 -6
  39. package/build/utils/crdt-user-selections.cjs.map +2 -2
  40. package/build/utils/crdt-utils.cjs +54 -2
  41. package/build/utils/crdt-utils.cjs.map +2 -2
  42. package/build/utils/crdt.cjs +4 -23
  43. package/build/utils/crdt.cjs.map +2 -2
  44. package/build/utils/index.cjs +3 -0
  45. package/build/utils/index.cjs.map +2 -2
  46. package/build/utils/normalize-query-for-resolution.cjs +35 -0
  47. package/build/utils/normalize-query-for-resolution.cjs.map +7 -0
  48. package/build-module/actions.mjs +17 -25
  49. package/build-module/actions.mjs.map +2 -2
  50. package/build-module/awareness/post-editor-awareness.mjs +46 -6
  51. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  52. package/build-module/entities.mjs +33 -7
  53. package/build-module/entities.mjs.map +2 -2
  54. package/build-module/hooks/use-entity-prop.mjs +2 -1
  55. package/build-module/hooks/use-entity-prop.mjs.map +2 -2
  56. package/build-module/hooks/use-post-editor-awareness-state.mjs +81 -2
  57. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  58. package/build-module/index.mjs +2 -0
  59. package/build-module/index.mjs.map +2 -2
  60. package/build-module/private-apis.mjs +6 -2
  61. package/build-module/private-apis.mjs.map +2 -2
  62. package/build-module/queried-data/get-query-parts.mjs +7 -0
  63. package/build-module/queried-data/get-query-parts.mjs.map +2 -2
  64. package/build-module/queried-data/selectors.mjs +19 -5
  65. package/build-module/queried-data/selectors.mjs.map +2 -2
  66. package/build-module/reducer.mjs +6 -0
  67. package/build-module/reducer.mjs.map +2 -2
  68. package/build-module/resolvers.mjs +112 -75
  69. package/build-module/resolvers.mjs.map +2 -2
  70. package/build-module/selectors.mjs +28 -0
  71. package/build-module/selectors.mjs.map +2 -2
  72. package/build-module/sync.mjs +2 -0
  73. package/build-module/sync.mjs.map +2 -2
  74. package/build-module/types.mjs +9 -0
  75. package/build-module/types.mjs.map +4 -4
  76. package/build-module/utils/block-selection-history.mjs +5 -2
  77. package/build-module/utils/block-selection-history.mjs.map +2 -2
  78. package/build-module/utils/crdt-blocks.mjs +17 -3
  79. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  80. package/build-module/utils/crdt-selection.mjs +8 -2
  81. package/build-module/utils/crdt-selection.mjs.map +2 -2
  82. package/build-module/utils/crdt-user-selections.mjs +10 -7
  83. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  84. package/build-module/utils/crdt-utils.mjs +51 -1
  85. package/build-module/utils/crdt-utils.mjs.map +2 -2
  86. package/build-module/utils/crdt.mjs +4 -23
  87. package/build-module/utils/crdt.mjs.map +2 -2
  88. package/build-module/utils/index.mjs +2 -0
  89. package/build-module/utils/index.mjs.map +2 -2
  90. package/build-module/utils/normalize-query-for-resolution.mjs +14 -0
  91. package/build-module/utils/normalize-query-for-resolution.mjs.map +7 -0
  92. package/build-types/actions.d.ts.map +1 -1
  93. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  94. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  95. package/build-types/awareness/types.d.ts +1 -1
  96. package/build-types/awareness/types.d.ts.map +1 -1
  97. package/build-types/entities.d.ts +1 -1
  98. package/build-types/entities.d.ts.map +1 -1
  99. package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
  100. package/build-types/hooks/use-post-editor-awareness-state.d.ts +34 -10
  101. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  102. package/build-types/index.d.ts +2 -0
  103. package/build-types/index.d.ts.map +1 -1
  104. package/build-types/private-apis.d.ts.map +1 -1
  105. package/build-types/queried-data/get-query-parts.d.ts +7 -0
  106. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  107. package/build-types/queried-data/selectors.d.ts.map +1 -1
  108. package/build-types/reducer.d.ts.map +1 -1
  109. package/build-types/resolvers.d.ts +2 -1
  110. package/build-types/resolvers.d.ts.map +1 -1
  111. package/build-types/selectors.d.ts +17 -0
  112. package/build-types/selectors.d.ts.map +1 -1
  113. package/build-types/sync.d.ts +2 -2
  114. package/build-types/sync.d.ts.map +1 -1
  115. package/build-types/types.d.ts +18 -1
  116. package/build-types/types.d.ts.map +1 -1
  117. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  118. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  119. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  120. package/build-types/utils/crdt-user-selections.d.ts +9 -5
  121. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  122. package/build-types/utils/crdt-utils.d.ts +20 -0
  123. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  124. package/build-types/utils/crdt.d.ts +6 -7
  125. package/build-types/utils/crdt.d.ts.map +1 -1
  126. package/build-types/utils/index.d.ts +1 -0
  127. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  128. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  129. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  130. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  131. package/package.json +18 -18
  132. package/src/actions.js +25 -40
  133. package/src/awareness/post-editor-awareness.ts +106 -7
  134. package/src/awareness/test/post-editor-awareness.ts +50 -10
  135. package/src/awareness/types.ts +1 -1
  136. package/src/entities.js +38 -6
  137. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  138. package/src/hooks/use-entity-prop.js +2 -0
  139. package/src/hooks/use-post-editor-awareness-state.ts +160 -8
  140. package/src/index.js +1 -0
  141. package/src/private-apis.js +6 -2
  142. package/src/queried-data/get-query-parts.js +13 -0
  143. package/src/queried-data/selectors.js +33 -8
  144. package/src/queried-data/test/get-query-parts.js +34 -0
  145. package/src/queried-data/test/selectors.js +183 -0
  146. package/src/reducer.js +11 -0
  147. package/src/resolvers.js +136 -88
  148. package/src/selectors.ts +56 -0
  149. package/src/sync.ts +2 -0
  150. package/src/test/entities.js +185 -1
  151. package/src/test/resolvers.js +64 -11
  152. package/src/test/selectors.js +150 -0
  153. package/src/test/store.js +66 -0
  154. package/src/types.ts +26 -1
  155. package/src/utils/block-selection-history.ts +5 -2
  156. package/src/utils/crdt-blocks.ts +32 -3
  157. package/src/utils/crdt-selection.ts +8 -2
  158. package/src/utils/crdt-user-selections.ts +20 -8
  159. package/src/utils/crdt-utils.ts +99 -0
  160. package/src/utils/crdt.ts +8 -32
  161. package/src/utils/index.js +1 -0
  162. package/src/utils/normalize-query-for-resolution.js +23 -0
  163. package/src/utils/test/crdt-blocks.ts +146 -0
  164. package/src/utils/test/crdt-user-selections.ts +5 -0
  165. package/src/utils/test/crdt-utils.ts +387 -0
  166. package/src/utils/test/crdt.ts +120 -53
@@ -172,16 +172,69 @@ describe( 'getEntityRecord', () => {
172
172
  editRecord: expect.any( Function ),
173
173
  getEditedRecord: expect.any( Function ),
174
174
  onStatusChange: expect.any( Function ),
175
+ persistCRDTDoc: expect.any( Function ),
175
176
  refetchRecord: expect.any( Function ),
176
177
  restoreUndoMeta: expect.any( Function ),
177
- saveRecord: expect.any( Function ),
178
178
  }
179
179
  );
180
180
  } );
181
181
 
182
- it( 'saveRecord fetches edited record and saves full entity record', async () => {
183
- const POST_RECORD = { id: 1, title: 'Test Post' };
184
- const EDITED_RECORD = { id: 1, title: 'Edited Post' };
182
+ it( 'persistCRDTDoc fetches edited record and does not save full entity record when the entity does not support meta', async () => {
183
+ const ENTITY_RECORD = { id: 1, title: 'Test Record' };
184
+ const EDITED_RECORD = { id: 1, title: 'Edited Record' };
185
+ const ENTITY_RESPONSE = {
186
+ json: () => Promise.resolve( ENTITY_RECORD ),
187
+ };
188
+ const ENTITIES_WITH_SYNC = [
189
+ {
190
+ name: 'bar',
191
+ kind: 'foo',
192
+ baseURL: '/wp/v2/foo',
193
+ baseURLParams: { context: 'edit' },
194
+ syncConfig: {},
195
+ },
196
+ ];
197
+
198
+ dispatch.saveEntityRecord = jest.fn();
199
+
200
+ const resolveSelectWithSync = {
201
+ getEntitiesConfig: jest.fn( () => ENTITIES_WITH_SYNC ),
202
+ getEditedEntityRecord: jest.fn( () =>
203
+ Promise.resolve( EDITED_RECORD )
204
+ ),
205
+ };
206
+
207
+ triggerFetch.mockImplementation( () => ENTITY_RESPONSE );
208
+
209
+ await getEntityRecord(
210
+ 'foo',
211
+ 'bar',
212
+ 1
213
+ )( {
214
+ dispatch,
215
+ registry,
216
+ resolveSelect: resolveSelectWithSync,
217
+ } );
218
+
219
+ // Extract the handlers passed to syncManager.load.
220
+ const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
221
+
222
+ // Call persistCRDTDoc and wait for the internal promise chain.
223
+ handlers.persistCRDTDoc();
224
+ await resolveSelectWithSync.getEditedEntityRecord();
225
+
226
+ // Should have fetched the full edited entity record.
227
+ expect(
228
+ resolveSelectWithSync.getEditedEntityRecord
229
+ ).toHaveBeenCalledWith( 'foo', 'bar', 1 );
230
+
231
+ // Should not have called saveEntityRecord.
232
+ expect( dispatch.saveEntityRecord ).not.toHaveBeenCalled();
233
+ } );
234
+
235
+ it( 'persistCRDTDoc fetches edited record and saves full entity record', async () => {
236
+ const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
237
+ const EDITED_RECORD = { id: 1, title: 'Edited Post', meta: {} };
185
238
  const POST_RESPONSE = {
186
239
  json: () => Promise.resolve( POST_RECORD ),
187
240
  };
@@ -219,8 +272,8 @@ describe( 'getEntityRecord', () => {
219
272
  // Extract the handlers passed to syncManager.load.
220
273
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
221
274
 
222
- // Call saveRecord and wait for the internal promise chain.
223
- handlers.saveRecord();
275
+ // Call persistCRDTDoc and wait for the internal promise chain.
276
+ handlers.persistCRDTDoc();
224
277
  await resolveSelectWithSync.getEditedEntityRecord();
225
278
 
226
279
  // Should have fetched the full edited entity record.
@@ -236,8 +289,8 @@ describe( 'getEntityRecord', () => {
236
289
  );
237
290
  } );
238
291
 
239
- it( 'saveRecord saves even when there are no unsaved edits', async () => {
240
- const POST_RECORD = { id: 1, title: 'Test Post' };
292
+ it( 'persistCRDTDoc saves even when there are no unsaved edits', async () => {
293
+ const POST_RECORD = { id: 1, title: 'Test Post', meta: {} };
241
294
  const POST_RESPONSE = {
242
295
  json: () => Promise.resolve( POST_RECORD ),
243
296
  };
@@ -275,8 +328,8 @@ describe( 'getEntityRecord', () => {
275
328
 
276
329
  const handlers = syncManager.load.mock.calls[ 0 ][ 4 ];
277
330
 
278
- // Call saveRecord and wait for the internal promise chain.
279
- handlers.saveRecord();
331
+ // Call persistCRDTDoc and wait for the internal promise chain.
332
+ handlers.persistCRDTDoc();
280
333
  await resolveSelectWithSync.getEditedEntityRecord();
281
334
 
282
335
  // Should save the record even with no edits (the whole point of the fix).
@@ -336,9 +389,9 @@ describe( 'getEntityRecord', () => {
336
389
  editRecord: expect.any( Function ),
337
390
  getEditedRecord: expect.any( Function ),
338
391
  onStatusChange: expect.any( Function ),
392
+ persistCRDTDoc: expect.any( Function ),
339
393
  refetchRecord: expect.any( Function ),
340
394
  restoreUndoMeta: expect.any( Function ),
341
- saveRecord: expect.any( Function ),
342
395
  }
343
396
  );
344
397
  } );
@@ -23,6 +23,7 @@ import {
23
23
  getCurrentUser,
24
24
  getRevisions,
25
25
  getRevision,
26
+ hasRevision,
26
27
  } from '../selectors';
27
28
 
28
29
  describe( 'getEntityRecord', () => {
@@ -1215,3 +1216,152 @@ describe( 'getRevision', () => {
1215
1216
  } );
1216
1217
  } );
1217
1218
  } );
1219
+
1220
+ describe( 'hasRevision', () => {
1221
+ it( 'returns false if revision has not been received', () => {
1222
+ const state = deepFreeze( {
1223
+ entities: {
1224
+ records: {
1225
+ postType: {
1226
+ post: {
1227
+ revisions: {
1228
+ 1: {
1229
+ items: {},
1230
+ itemIsComplete: {},
1231
+ queries: {},
1232
+ },
1233
+ },
1234
+ },
1235
+ },
1236
+ },
1237
+ },
1238
+ } );
1239
+ expect( hasRevision( state, 'postType', 'post', 1, 10 ) ).toBe( false );
1240
+ } );
1241
+
1242
+ it( 'returns false if parent record does not exist', () => {
1243
+ const state = deepFreeze( {
1244
+ entities: {
1245
+ records: {},
1246
+ },
1247
+ } );
1248
+ expect( hasRevision( state, 'postType', 'post', 1, 10 ) ).toBe( false );
1249
+ } );
1250
+
1251
+ it( 'returns true when full revision exists and no fields query', () => {
1252
+ const state = deepFreeze( {
1253
+ entities: {
1254
+ records: {
1255
+ postType: {
1256
+ post: {
1257
+ revisions: {
1258
+ 1: {
1259
+ items: {
1260
+ default: {
1261
+ 10: {
1262
+ id: 10,
1263
+ content: 'chicken',
1264
+ parent: 1,
1265
+ },
1266
+ },
1267
+ },
1268
+ itemIsComplete: {
1269
+ default: {
1270
+ 10: true,
1271
+ },
1272
+ },
1273
+ queries: {},
1274
+ },
1275
+ },
1276
+ },
1277
+ },
1278
+ },
1279
+ },
1280
+ } );
1281
+ expect( hasRevision( state, 'postType', 'post', 1, 10 ) ).toBe( true );
1282
+ } );
1283
+
1284
+ it( 'returns true when requested fields exist on the revision', () => {
1285
+ const state = deepFreeze( {
1286
+ entities: {
1287
+ records: {
1288
+ postType: {
1289
+ post: {
1290
+ revisions: {
1291
+ 1: {
1292
+ items: {
1293
+ default: {
1294
+ 10: {
1295
+ id: 10,
1296
+ content: 'chicken',
1297
+ title: { raw: 'egg' },
1298
+ parent: 1,
1299
+ },
1300
+ },
1301
+ },
1302
+ itemIsComplete: {
1303
+ default: {
1304
+ 10: true,
1305
+ },
1306
+ },
1307
+ queries: {},
1308
+ },
1309
+ },
1310
+ },
1311
+ },
1312
+ },
1313
+ },
1314
+ } );
1315
+ expect(
1316
+ hasRevision( state, 'postType', 'post', 1, 10, {
1317
+ _fields: [ 'id', 'content' ],
1318
+ } )
1319
+ ).toBe( true );
1320
+ expect(
1321
+ hasRevision( state, 'postType', 'post', 1, 10, {
1322
+ _fields: [ 'id', 'title.raw' ],
1323
+ } )
1324
+ ).toBe( true );
1325
+ } );
1326
+
1327
+ it( 'returns false when requested fields are missing', () => {
1328
+ const state = deepFreeze( {
1329
+ entities: {
1330
+ records: {
1331
+ postType: {
1332
+ post: {
1333
+ revisions: {
1334
+ 1: {
1335
+ items: {
1336
+ default: {
1337
+ 10: {
1338
+ id: 10,
1339
+ parent: 1,
1340
+ },
1341
+ },
1342
+ },
1343
+ itemIsComplete: {
1344
+ default: {
1345
+ 10: true,
1346
+ },
1347
+ },
1348
+ queries: {},
1349
+ },
1350
+ },
1351
+ },
1352
+ },
1353
+ },
1354
+ },
1355
+ } );
1356
+ expect(
1357
+ hasRevision( state, 'postType', 'post', 1, 10, {
1358
+ _fields: [ 'id', 'content' ],
1359
+ } )
1360
+ ).toBe( false );
1361
+ expect(
1362
+ hasRevision( state, 'postType', 'post', 1, 10, {
1363
+ _fields: [ 'id', 'title.raw' ],
1364
+ } )
1365
+ ).toBe( false );
1366
+ } );
1367
+ } );
package/src/test/store.js CHANGED
@@ -36,6 +36,10 @@ function createTestRegistry() {
36
36
  __unstable_rest_base: 'posts',
37
37
  supportsPagination: true,
38
38
  revisionKey: 'id',
39
+ getRevisionsUrl: ( parentId, revisionId ) =>
40
+ `/wp/v2/posts/${ parentId }/revisions${
41
+ revisionId ? '/' + revisionId : ''
42
+ }`,
39
43
  };
40
44
 
41
45
  // Add the post entity to the store
@@ -258,3 +262,65 @@ describe( 'clearEntityRecordEdits', () => {
258
262
  ).toEqual( select.getRawEntityRecord( 'postType', 'post', post.id ) );
259
263
  } );
260
264
  } );
265
+
266
+ describe( 'getRevisions', () => {
267
+ const KIND = 'postType';
268
+ const NAME = 'post';
269
+ const RECORD_KEY = 1;
270
+ const REVISIONS = [ { id: 2 }, { id: 3 }, { id: 4 } ];
271
+
272
+ let registry;
273
+
274
+ beforeEach( () => {
275
+ registry = createTestRegistry();
276
+ triggerFetch.mockReset();
277
+ } );
278
+
279
+ it( 'preserves all revisions when getRevision resolves after getRevisions', async () => {
280
+ let resolveSlowFetch;
281
+ const slowFetchPromise = new Promise( ( resolve ) => {
282
+ resolveSlowFetch = resolve;
283
+ } );
284
+
285
+ triggerFetch.mockImplementation( ( { path } ) => {
286
+ if ( path && path.includes( 'revisions' ) ) {
287
+ // Single revision fetch: return slow promise.
288
+ if ( /revisions\/\d+/.test( path ) ) {
289
+ return slowFetchPromise;
290
+ }
291
+ // Collection fetch: return immediately.
292
+ return Promise.resolve( {
293
+ json: () => Promise.resolve( REVISIONS ),
294
+ headers: { get: () => String( REVISIONS.length ) },
295
+ } );
296
+ }
297
+ return Promise.resolve( {} );
298
+ } );
299
+
300
+ const resolveSelectStore = registry.resolveSelect( coreDataStore );
301
+
302
+ // Start getRevision first (slow), then getRevisions (fast).
303
+ const revisionPromise = resolveSelectStore.getRevision(
304
+ KIND,
305
+ NAME,
306
+ RECORD_KEY,
307
+ 1,
308
+ { context: 'edit' }
309
+ );
310
+ await resolveSelectStore.getRevisions( KIND, NAME, RECORD_KEY, {
311
+ context: 'edit',
312
+ } );
313
+
314
+ // Now resolve the slow single-revision fetch.
315
+ resolveSlowFetch( REVISIONS[ 0 ] );
316
+ await revisionPromise;
317
+
318
+ // Wait for all pending thunks (receiveRevisions) to settle.
319
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
320
+
321
+ const allRevisions = registry
322
+ .select( coreDataStore )
323
+ .getRevisions( KIND, NAME, RECORD_KEY, { context: 'edit' } );
324
+ expect( allRevisions.map( ( r ) => r.id ) ).toEqual( [ 2, 3, 4 ] );
325
+ } );
326
+ } );
package/src/types.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * WordPress dependencies
3
3
  */
4
- import type { Y } from '@wordpress/sync';
4
+ import type { ConnectionStatusDisconnected, Y } from '@wordpress/sync';
5
5
 
6
6
  /**
7
7
  * Internal dependencies
8
8
  */
9
9
  import type { SelectionType } from './utils/crdt-user-selections';
10
10
 
11
+ export type { ConnectionStatus } from '@wordpress/sync';
12
+
13
+ export type ConnectionError = NonNullable<
14
+ ConnectionStatusDisconnected[ 'error' ]
15
+ >;
16
+
11
17
  export interface AnyFunction {
12
18
  ( ...args: any[] ): any;
13
19
  }
@@ -66,6 +72,16 @@ export type CursorPosition = {
66
72
  absoluteOffset: number;
67
73
  };
68
74
 
75
+ /**
76
+ * The direction of a text selection, indicating where the caret sits.
77
+ */
78
+ export enum SelectionDirection {
79
+ /** The caret is at the end of the selection (default / left-to-right). */
80
+ Forward = 'f',
81
+ /** The caret is at the start of the selection (right-to-left). */
82
+ Backward = 'b',
83
+ }
84
+
69
85
  export type SelectionNone = {
70
86
  // The user has not made a selection.
71
87
  type: SelectionType.None;
@@ -86,6 +102,8 @@ export type SelectionInOneBlock = {
86
102
  type: SelectionType.SelectionInOneBlock;
87
103
  cursorStartPosition: CursorPosition;
88
104
  cursorEndPosition: CursorPosition;
105
+ // The direction of the selection, indicating where the caret sits.
106
+ selectionDirection?: SelectionDirection;
89
107
  };
90
108
 
91
109
  export type SelectionInMultipleBlocks = {
@@ -95,6 +113,8 @@ export type SelectionInMultipleBlocks = {
95
113
  type: SelectionType.SelectionInMultipleBlocks;
96
114
  cursorStartPosition: CursorPosition;
97
115
  cursorEndPosition: CursorPosition;
116
+ // The direction of the selection, indicating where the caret sits.
117
+ selectionDirection?: SelectionDirection;
98
118
  };
99
119
 
100
120
  export type SelectionWholeBlock = {
@@ -111,3 +131,8 @@ export type SelectionState =
111
131
  | SelectionInOneBlock
112
132
  | SelectionInMultipleBlocks
113
133
  | SelectionWholeBlock;
134
+
135
+ export interface ResolvedSelection {
136
+ richTextOffset: number | null;
137
+ localClientId: string | null;
138
+ }
@@ -9,7 +9,10 @@ import { Y } from '@wordpress/sync';
9
9
  /**
10
10
  * Internal dependencies
11
11
  */
12
- import { findBlockByClientIdInDoc } from './crdt-utils';
12
+ import {
13
+ findBlockByClientIdInDoc,
14
+ richTextOffsetToHtmlIndex,
15
+ } from './crdt-utils';
13
16
  import type { WPBlockSelection, WPSelection } from '../types';
14
17
 
15
18
  // Default size for selection history (not including current selection)
@@ -163,7 +166,7 @@ function convertWPBlockSelectionToSelection(
163
166
  const offset = selection.offset ?? 0;
164
167
  const relativePosition = Y.createRelativePositionFromTypeIndex(
165
168
  changedYText,
166
- offset
169
+ richTextOffsetToHtmlIndex( changedYText.toString(), offset )
167
170
  );
168
171
 
169
172
  return {
@@ -62,6 +62,37 @@ export type YBlockAttributes = Y.Map< Y.Text | unknown >;
62
62
 
63
63
  const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
64
64
 
65
+ /**
66
+ * Recursively walk an attribute value and convert any RichTextData instances
67
+ * to their string (HTML) representation. This is necessary for array-type and
68
+ * object-type attributes, which can contain nested RichTextData.
69
+ *
70
+ * @param value The attribute value to serialize.
71
+ * @return The value with all RichTextData instances replaced by strings.
72
+ */
73
+ function serializeAttributeValue( value: unknown ): unknown {
74
+ if ( value instanceof RichTextData ) {
75
+ return value.valueOf();
76
+ }
77
+
78
+ // e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
79
+ if ( Array.isArray( value ) ) {
80
+ return value.map( serializeAttributeValue );
81
+ }
82
+
83
+ // e.g. a single row inside core/table `body`: { cells: [ ... ] }
84
+ if ( value && typeof value === 'object' ) {
85
+ const result: Record< string, unknown > = {};
86
+
87
+ for ( const [ k, v ] of Object.entries( value ) ) {
88
+ result[ k ] = serializeAttributeValue( v );
89
+ }
90
+ return result;
91
+ }
92
+
93
+ return value;
94
+ }
95
+
65
96
  function makeBlockAttributesSerializable(
66
97
  blockName: string,
67
98
  attributes: BlockAttributes
@@ -73,9 +104,7 @@ function makeBlockAttributesSerializable(
73
104
  continue;
74
105
  }
75
106
 
76
- if ( value instanceof RichTextData ) {
77
- newAttributes[ key ] = value.valueOf();
78
- }
107
+ newAttributes[ key ] = serializeAttributeValue( value );
79
108
  }
80
109
  return newAttributes;
81
110
  }
@@ -18,7 +18,10 @@ import {
18
18
  type YFullSelection,
19
19
  type YSelection,
20
20
  } from './block-selection-history';
21
- import { findBlockByClientIdInDoc } from './crdt-utils';
21
+ import {
22
+ findBlockByClientIdInDoc,
23
+ htmlIndexToRichTextOffset,
24
+ } from './crdt-utils';
22
25
  import type { WPBlockSelection, WPSelection } from '../types';
23
26
 
24
27
  // WeakMap to store BlockSelectionHistory instances per Y.Doc
@@ -74,7 +77,10 @@ function convertYSelectionToBlockSelection(
74
77
  return {
75
78
  clientId,
76
79
  attributeKey,
77
- offset: absolutePosition.index,
80
+ offset: htmlIndexToRichTextOffset(
81
+ absolutePosition.type.toString(),
82
+ absolutePosition.index
83
+ ),
78
84
  };
79
85
  }
80
86
  } else if ( ySelection.type === YSelectionType.BlockSelection ) {
@@ -12,7 +12,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
12
12
  import { CRDT_RECORD_MAP_KEY } from '../sync';
13
13
  import type { YPostRecord } from './crdt';
14
14
  import type { YBlock, YBlocks } from './crdt-blocks';
15
- import { getRootMap } from './crdt-utils';
15
+ import { getRootMap, richTextOffsetToHtmlIndex } from './crdt-utils';
16
16
  import type {
17
17
  AbsoluteBlockIndexPath,
18
18
  WPBlockSelection,
@@ -22,6 +22,7 @@ import type {
22
22
  SelectionInOneBlock,
23
23
  SelectionInMultipleBlocks,
24
24
  SelectionWholeBlock,
25
+ SelectionDirection,
25
26
  CursorPosition,
26
27
  } from '../types';
27
28
 
@@ -44,16 +45,20 @@ export enum SelectionType {
44
45
  * differ between the block-editor store and the Yjs document (e.g. in "Show
45
46
  * Template" mode).
46
47
  *
47
- * @param selectionStart - The start position of the selection
48
- * @param selectionEnd - The end position of the selection
49
- * @param yDoc - The Yjs document
48
+ * @param selectionStart - The start position of the selection
49
+ * @param selectionEnd - The end position of the selection
50
+ * @param yDoc - The Yjs document
51
+ * @param options - Optional parameters
52
+ * @param options.selectionDirection - The direction of the selection (forward or backward)
50
53
  * @return The SelectionState
51
54
  */
52
55
  export function getSelectionState(
53
56
  selectionStart: WPBlockSelection,
54
57
  selectionEnd: WPBlockSelection,
55
- yDoc: Y.Doc
58
+ yDoc: Y.Doc,
59
+ options?: { selectionDirection?: SelectionDirection }
56
60
  ): SelectionState {
61
+ const { selectionDirection } = options ?? {};
57
62
  const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY );
58
63
  const yBlocks = ymap.get( 'blocks' );
59
64
 
@@ -122,6 +127,7 @@ export function getSelectionState(
122
127
  type: SelectionType.SelectionInOneBlock,
123
128
  cursorStartPosition,
124
129
  cursorEndPosition,
130
+ selectionDirection,
125
131
  };
126
132
  }
127
133
 
@@ -137,6 +143,7 @@ export function getSelectionState(
137
143
  type: SelectionType.SelectionInMultipleBlocks,
138
144
  cursorStartPosition,
139
145
  cursorEndPosition,
146
+ selectionDirection,
140
147
  };
141
148
  }
142
149
 
@@ -171,7 +178,7 @@ function getCursorPosition(
171
178
 
172
179
  const relativePosition = Y.createRelativePositionFromTypeIndex(
173
180
  currentYText,
174
- selection.offset
181
+ richTextOffsetToHtmlIndex( currentYText.toString(), selection.offset )
175
182
  );
176
183
 
177
184
  return {
@@ -315,7 +322,9 @@ export function areSelectionsStatesEqual(
315
322
  areCursorPositionsEqual(
316
323
  selection1.cursorEndPosition,
317
324
  ( selection2 as SelectionInOneBlock ).cursorEndPosition
318
- )
325
+ ) &&
326
+ selection1.selectionDirection ===
327
+ ( selection2 as SelectionInOneBlock ).selectionDirection
319
328
  );
320
329
 
321
330
  case SelectionType.SelectionInMultipleBlocks:
@@ -329,7 +338,10 @@ export function areSelectionsStatesEqual(
329
338
  selection1.cursorEndPosition,
330
339
  ( selection2 as SelectionInMultipleBlocks )
331
340
  .cursorEndPosition
332
- )
341
+ ) &&
342
+ selection1.selectionDirection ===
343
+ ( selection2 as SelectionInMultipleBlocks )
344
+ .selectionDirection
333
345
  );
334
346
  case SelectionType.WholeBlock:
335
347
  return Y.compareRelativePositions(