@wordpress/core-data 7.40.2-next.v.202602241322.0 → 7.41.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 (100) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/actions.cjs +1 -1
  3. package/build/actions.cjs.map +2 -2
  4. package/build/awareness/types.cjs.map +1 -1
  5. package/build/entities.cjs +17 -10
  6. package/build/entities.cjs.map +2 -2
  7. package/build/hooks/use-post-editor-awareness-state.cjs +38 -0
  8. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  9. package/build/private-actions.cjs +7 -2
  10. package/build/private-actions.cjs.map +2 -2
  11. package/build/private-apis.cjs +4 -1
  12. package/build/private-apis.cjs.map +2 -2
  13. package/build/private-selectors.cjs +7 -2
  14. package/build/private-selectors.cjs.map +2 -2
  15. package/build/reducer.cjs +11 -1
  16. package/build/reducer.cjs.map +2 -2
  17. package/build/resolvers.cjs +15 -12
  18. package/build/resolvers.cjs.map +2 -2
  19. package/build/selectors.cjs.map +2 -2
  20. package/build/sync.cjs +5 -5
  21. package/build/sync.cjs.map +1 -1
  22. package/build/types.cjs.map +1 -1
  23. package/build/utils/crdt-blocks.cjs +50 -31
  24. package/build/utils/crdt-blocks.cjs.map +2 -2
  25. package/build/utils/crdt-selection.cjs +46 -18
  26. package/build/utils/crdt-selection.cjs.map +2 -2
  27. package/build/utils/crdt.cjs +12 -1
  28. package/build/utils/crdt.cjs.map +2 -2
  29. package/build-module/actions.mjs +1 -1
  30. package/build-module/actions.mjs.map +2 -2
  31. package/build-module/entities.mjs +19 -11
  32. package/build-module/entities.mjs.map +2 -2
  33. package/build-module/hooks/use-post-editor-awareness-state.mjs +37 -0
  34. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  35. package/build-module/private-actions.mjs +5 -1
  36. package/build-module/private-actions.mjs.map +2 -2
  37. package/build-module/private-apis.mjs +6 -2
  38. package/build-module/private-apis.mjs.map +2 -2
  39. package/build-module/private-selectors.mjs +5 -1
  40. package/build-module/private-selectors.mjs.map +2 -2
  41. package/build-module/reducer.mjs +10 -1
  42. package/build-module/reducer.mjs.map +2 -2
  43. package/build-module/resolvers.mjs +15 -12
  44. package/build-module/resolvers.mjs.map +2 -2
  45. package/build-module/selectors.mjs.map +2 -2
  46. package/build-module/sync.mjs +3 -3
  47. package/build-module/sync.mjs.map +1 -1
  48. package/build-module/utils/crdt-blocks.mjs +50 -31
  49. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  50. package/build-module/utils/crdt-selection.mjs +45 -18
  51. package/build-module/utils/crdt-selection.mjs.map +2 -2
  52. package/build-module/utils/crdt.mjs +16 -6
  53. package/build-module/utils/crdt.mjs.map +2 -2
  54. package/build-types/awareness/types.d.ts +5 -0
  55. package/build-types/awareness/types.d.ts.map +1 -1
  56. package/build-types/entities.d.ts +1 -1
  57. package/build-types/entities.d.ts.map +1 -1
  58. package/build-types/hooks/use-post-editor-awareness-state.d.ts +10 -1
  59. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  60. package/build-types/index.d.ts.map +1 -1
  61. package/build-types/private-actions.d.ts +1 -0
  62. package/build-types/private-actions.d.ts.map +1 -1
  63. package/build-types/private-apis.d.ts.map +1 -1
  64. package/build-types/private-selectors.d.ts +7 -0
  65. package/build-types/private-selectors.d.ts.map +1 -1
  66. package/build-types/reducer.d.ts +15 -0
  67. package/build-types/reducer.d.ts.map +1 -1
  68. package/build-types/resolvers.d.ts.map +1 -1
  69. package/build-types/selectors.d.ts +1 -0
  70. package/build-types/selectors.d.ts.map +1 -1
  71. package/build-types/sync.d.ts +2 -2
  72. package/build-types/sync.d.ts.map +1 -1
  73. package/build-types/types.d.ts +1 -0
  74. package/build-types/types.d.ts.map +1 -1
  75. package/build-types/utils/crdt-blocks.d.ts +1 -1
  76. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  77. package/build-types/utils/crdt-selection.d.ts +10 -0
  78. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  79. package/build-types/utils/crdt.d.ts +1 -0
  80. package/build-types/utils/crdt.d.ts.map +1 -1
  81. package/package.json +18 -18
  82. package/src/actions.js +2 -2
  83. package/src/awareness/types.ts +6 -0
  84. package/src/entities.js +23 -11
  85. package/src/hooks/use-post-editor-awareness-state.ts +70 -0
  86. package/src/private-actions.js +13 -0
  87. package/src/private-apis.js +4 -0
  88. package/src/private-selectors.ts +10 -0
  89. package/src/reducer.js +21 -0
  90. package/src/resolvers.js +21 -15
  91. package/src/selectors.ts +1 -0
  92. package/src/sync.ts +2 -2
  93. package/src/test/entities.js +47 -14
  94. package/src/test/resolvers.js +46 -80
  95. package/src/types.ts +1 -0
  96. package/src/utils/crdt-blocks.ts +113 -47
  97. package/src/utils/crdt-selection.ts +84 -24
  98. package/src/utils/crdt.ts +23 -7
  99. package/src/utils/test/crdt-blocks.ts +938 -0
  100. package/src/utils/test/crdt.ts +136 -10
@@ -832,8 +832,8 @@ describe( 'canUser', () => {
832
832
  batch: ( callback ) => callback(),
833
833
  };
834
834
  dispatch = Object.assign( jest.fn(), {
835
- receiveUserPermission: jest.fn(),
836
- finishResolution: jest.fn(),
835
+ receiveUserPermissions: jest.fn(),
836
+ finishResolutions: jest.fn(),
837
837
  } );
838
838
  triggerFetch.mockReset();
839
839
  } );
@@ -859,7 +859,7 @@ describe( 'canUser', () => {
859
859
  parse: false,
860
860
  } );
861
861
 
862
- expect( dispatch.receiveUserPermission ).not.toHaveBeenCalled();
862
+ expect( dispatch.receiveUserPermissions ).not.toHaveBeenCalled();
863
863
  } );
864
864
 
865
865
  it( 'throws an error when an entity resource object is malformed', async () => {
@@ -888,9 +888,8 @@ describe( 'canUser', () => {
888
888
  parse: false,
889
889
  } );
890
890
 
891
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
892
- 'create/media',
893
- false
891
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
892
+ expect.objectContaining( { 'create/media': false } )
894
893
  );
895
894
  } );
896
895
 
@@ -911,9 +910,8 @@ describe( 'canUser', () => {
911
910
  parse: false,
912
911
  } );
913
912
 
914
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
915
- 'create/postType/attachment',
916
- false
913
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
914
+ expect.objectContaining( { 'create/postType/attachment': false } )
917
915
  );
918
916
  } );
919
917
 
@@ -933,9 +931,8 @@ describe( 'canUser', () => {
933
931
  parse: false,
934
932
  } );
935
933
 
936
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
937
- 'create/media',
938
- true
934
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
935
+ expect.objectContaining( { 'create/media': true } )
939
936
  );
940
937
  } );
941
938
 
@@ -956,9 +953,8 @@ describe( 'canUser', () => {
956
953
  parse: false,
957
954
  } );
958
955
 
959
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
960
- 'create/postType/attachment',
961
- true
956
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
957
+ expect.objectContaining( { 'create/postType/attachment': true } )
962
958
  );
963
959
  } );
964
960
 
@@ -979,9 +975,8 @@ describe( 'canUser', () => {
979
975
  parse: false,
980
976
  } );
981
977
 
982
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
983
- 'create/blocks/123',
984
- true
978
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
979
+ expect.objectContaining( { 'create/blocks/123': true } )
985
980
  );
986
981
  } );
987
982
 
@@ -1006,9 +1001,8 @@ describe( 'canUser', () => {
1006
1001
  parse: false,
1007
1002
  } );
1008
1003
 
1009
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1010
- 'create/postType/wp_block/123',
1011
- true
1004
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
1005
+ expect.objectContaining( { 'create/postType/wp_block/123': true } )
1012
1006
  );
1013
1007
  } );
1014
1008
 
@@ -1035,13 +1029,11 @@ describe( 'canUser', () => {
1035
1029
 
1036
1030
  expect( triggerFetch ).toHaveBeenCalledTimes( 1 );
1037
1031
 
1038
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1039
- 'create/blocks',
1040
- true
1041
- );
1042
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1043
- 'read/blocks',
1044
- true
1032
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
1033
+ expect.objectContaining( {
1034
+ 'create/blocks': true,
1035
+ 'read/blocks': true,
1036
+ } )
1045
1037
  );
1046
1038
  } );
1047
1039
 
@@ -1076,13 +1068,11 @@ describe( 'canUser', () => {
1076
1068
 
1077
1069
  expect( triggerFetch ).toHaveBeenCalledTimes( 1 );
1078
1070
 
1079
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1080
- 'create/postType/wp_block',
1081
- true
1082
- );
1083
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1084
- 'read/postType/wp_block',
1085
- true
1071
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
1072
+ expect.objectContaining( {
1073
+ 'create/postType/wp_block': true,
1074
+ 'read/postType/wp_block': true,
1075
+ } )
1086
1076
  );
1087
1077
  } );
1088
1078
 
@@ -1115,21 +1105,13 @@ describe( 'canUser', () => {
1115
1105
  'blocks'
1116
1106
  )( { dispatch, registry, resolveSelect } );
1117
1107
 
1118
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1119
- 'create/blocks',
1120
- true
1121
- );
1122
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1123
- 'read/blocks',
1124
- true
1125
- );
1126
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1127
- 'update/blocks',
1128
- false
1129
- );
1130
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1131
- 'delete/blocks',
1132
- false
1108
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
1109
+ expect.objectContaining( {
1110
+ 'create/blocks': true,
1111
+ 'read/blocks': true,
1112
+ 'update/blocks': false,
1113
+ 'delete/blocks': false,
1114
+ } )
1133
1115
  );
1134
1116
  } );
1135
1117
 
@@ -1168,21 +1150,13 @@ describe( 'canUser', () => {
1168
1150
 
1169
1151
  expect( triggerFetch ).toHaveBeenCalledTimes( 1 );
1170
1152
 
1171
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1172
- 'create/blocks/123',
1173
- true
1174
- );
1175
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1176
- 'read/blocks/123',
1177
- true
1178
- );
1179
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1180
- 'update/blocks/123',
1181
- true
1182
- );
1183
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1184
- 'delete/blocks/123',
1185
- true
1153
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
1154
+ expect.objectContaining( {
1155
+ 'create/blocks/123': true,
1156
+ 'read/blocks/123': true,
1157
+ 'update/blocks/123': true,
1158
+ 'delete/blocks/123': true,
1159
+ } )
1186
1160
  );
1187
1161
  } );
1188
1162
 
@@ -1221,21 +1195,13 @@ describe( 'canUser', () => {
1221
1195
 
1222
1196
  expect( triggerFetch ).toHaveBeenCalledTimes( 1 );
1223
1197
 
1224
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1225
- 'create/postType/wp_block/123',
1226
- true
1227
- );
1228
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1229
- 'read/postType/wp_block/123',
1230
- true
1231
- );
1232
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1233
- 'update/postType/wp_block/123',
1234
- true
1235
- );
1236
- expect( dispatch.receiveUserPermission ).toHaveBeenCalledWith(
1237
- 'delete/postType/wp_block/123',
1238
- true
1198
+ expect( dispatch.receiveUserPermissions ).toHaveBeenCalledWith(
1199
+ expect.objectContaining( {
1200
+ 'create/postType/wp_block/123': true,
1201
+ 'read/postType/wp_block/123': true,
1202
+ 'update/postType/wp_block/123': true,
1203
+ 'delete/postType/wp_block/123': true,
1204
+ } )
1239
1205
  );
1240
1206
  } );
1241
1207
  } );
package/src/types.ts CHANGED
@@ -44,6 +44,7 @@ export interface WPBlockSelection {
44
44
  export interface WPSelection {
45
45
  selectionEnd: WPBlockSelection;
46
46
  selectionStart: WPBlockSelection;
47
+ initialPosition?: number | null;
47
48
  }
48
49
 
49
50
  /**
@@ -39,7 +39,7 @@ export interface Block {
39
39
  }
40
40
 
41
41
  // A block as represented in the CRDT document (Y.Map).
42
- interface YBlockRecord extends YMapRecord {
42
+ export interface YBlockRecord extends YMapRecord {
43
43
  attributes: YBlockAttributes;
44
44
  clientId: string;
45
45
  innerBlocks: YBlocks;
@@ -283,43 +283,29 @@ export function mergeCrdtBlocks(
283
283
 
284
284
  Object.entries( value ).forEach(
285
285
  ( [ attributeName, attributeValue ] ) => {
286
- if (
287
- fastDeepEqual(
288
- currentAttributes?.get( attributeName ),
289
- attributeValue
290
- )
291
- ) {
292
- return;
293
- }
294
-
295
286
  const currentAttribute =
296
- currentAttributes.get( attributeName );
297
- const isRichText = isRichTextAttribute(
287
+ currentAttributes?.get( attributeName );
288
+
289
+ const isExpectedType = isExpectedAttributeType(
298
290
  block.name,
299
- attributeName
291
+ attributeName,
292
+ currentAttribute
300
293
  );
301
294
 
302
- if (
303
- isRichText &&
304
- 'string' === typeof attributeValue &&
305
- currentAttributes.has( attributeName ) &&
306
- currentAttribute instanceof Y.Text
307
- ) {
308
- // Rich text values are stored as persistent Y.Text instances.
309
- // Update the value with a delta in place.
310
- mergeRichTextUpdate(
295
+ const isAttributeChanged =
296
+ ! isExpectedType ||
297
+ ! fastDeepEqual(
311
298
  currentAttribute,
312
- attributeValue,
313
- cursorPosition
299
+ attributeValue
314
300
  );
315
- } else {
316
- currentAttributes.set(
301
+
302
+ if ( isAttributeChanged ) {
303
+ updateYBlockAttribute(
304
+ block.name,
317
305
  attributeName,
318
- createNewYAttributeValue(
319
- block.name,
320
- attributeName,
321
- attributeValue
322
- )
306
+ attributeValue,
307
+ currentAttributes,
308
+ cursorPosition
323
309
  );
324
310
  }
325
311
  }
@@ -422,45 +408,125 @@ function shouldBlockBeSynced( block: Block ): boolean {
422
408
  return true;
423
409
  }
424
410
 
425
- // Cache rich-text attributes for all block types.
426
- let cachedRichTextAttributes: Map< string, Map< string, true > >;
411
+ /**
412
+ * Update a single attribute on a Yjs block attributes map (currentAttributes).
413
+ *
414
+ * For rich-text attributes that already exist as Y.Text instances, the update
415
+ * is applied as a delta merge so that concurrent edits are preserved. All
416
+ * other attributes are replaced wholesale via `createNewYAttributeValue`.
417
+ *
418
+ * @param blockName The block type name, e.g. 'core/paragraph'.
419
+ * @param attributeName The name of the attribute to update, e.g. 'content'.
420
+ * @param attributeValue The new value for the attribute.
421
+ * @param currentAttributes The Y.Map holding the block's current attributes.
422
+ * @param cursorPosition The local cursor position, used when merging rich-text deltas.
423
+ */
424
+ function updateYBlockAttribute(
425
+ blockName: string,
426
+ attributeName: string,
427
+ attributeValue: unknown,
428
+ currentAttributes: YBlockAttributes,
429
+ cursorPosition: number | null
430
+ ): void {
431
+ const isRichText = isRichTextAttribute( blockName, attributeName );
432
+ const currentAttribute = currentAttributes.get( attributeName );
433
+
434
+ if (
435
+ isRichText &&
436
+ 'string' === typeof attributeValue &&
437
+ currentAttributes.has( attributeName ) &&
438
+ currentAttribute instanceof Y.Text
439
+ ) {
440
+ // Rich text values are stored as persistent Y.Text instances.
441
+ // Update the value with a delta in place.
442
+ mergeRichTextUpdate( currentAttribute, attributeValue, cursorPosition );
443
+ } else {
444
+ currentAttributes.set(
445
+ attributeName,
446
+ createNewYAttributeValue( blockName, attributeName, attributeValue )
447
+ );
448
+ }
449
+ }
450
+
451
+ // Cached types for block attributes.
452
+ let cachedBlockAttributeTypes: Map< string, Map< string, string > >;
427
453
 
428
454
  /**
429
- * Given a block name and attribute key, return true if the attribute is rich-text typed.
455
+ * Get the defined attribute type for a block attribute.
430
456
  *
431
457
  * @param blockName The name of the block, e.g. 'core/paragraph'.
432
- * @param attributeName The name of the attribute to check, e.g. 'content'.
433
- * @return True if the attribute is rich-text typed, false otherwise.
458
+ * @param attributeName The name of the attribute, e.g. 'content'.
459
+ * @return The type of the attribute, e.g. 'rich-text' or 'string'.
434
460
  */
435
- function isRichTextAttribute(
461
+ function getBlockAttributeType(
436
462
  blockName: string,
437
463
  attributeName: string
438
- ): boolean {
439
- if ( ! cachedRichTextAttributes ) {
464
+ ): string | undefined {
465
+ if ( ! cachedBlockAttributeTypes ) {
440
466
  // Parse the attributes for all blocks once.
441
- cachedRichTextAttributes = new Map< string, Map< string, true > >();
467
+ cachedBlockAttributeTypes = new Map< string, Map< string, string > >();
442
468
 
443
469
  for ( const blockType of getBlockTypes() as BlockType[] ) {
444
- const richTextAttributeMap = new Map< string, true >();
470
+ const blockAttributeTypeMap = new Map< string, string >();
445
471
 
446
472
  for ( const [ name, definition ] of Object.entries(
447
473
  blockType.attributes ?? {}
448
474
  ) ) {
449
- if ( 'rich-text' === definition.type ) {
450
- richTextAttributeMap.set( name, true );
475
+ if ( definition.type ) {
476
+ blockAttributeTypeMap.set( name, definition.type );
451
477
  }
452
478
  }
453
479
 
454
- cachedRichTextAttributes.set(
480
+ cachedBlockAttributeTypes.set(
455
481
  blockType.name,
456
- richTextAttributeMap
482
+ blockAttributeTypeMap
457
483
  );
458
484
  }
459
485
  }
460
486
 
461
- return (
462
- cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false
487
+ return cachedBlockAttributeTypes.get( blockName )?.get( attributeName );
488
+ }
489
+
490
+ /**
491
+ * Check if an attribute value is the expected type.
492
+ *
493
+ * @param blockName The name of the block, e.g. 'core/paragraph'.
494
+ * @param attributeName The name of the attribute, e.g. 'content'.
495
+ * @param attributeValue The current attribute value.
496
+ * @return True if the attribute type is expected, false otherwise.
497
+ */
498
+ function isExpectedAttributeType(
499
+ blockName: string,
500
+ attributeName: string,
501
+ attributeValue: unknown
502
+ ): boolean {
503
+ const expectedAttributeType = getBlockAttributeType(
504
+ blockName,
505
+ attributeName
463
506
  );
507
+
508
+ if ( expectedAttributeType === 'rich-text' ) {
509
+ return attributeValue instanceof Y.Text;
510
+ } else if ( expectedAttributeType === 'string' ) {
511
+ return typeof attributeValue === 'string';
512
+ }
513
+
514
+ // No other types comparisons use special logic.
515
+ return true;
516
+ }
517
+
518
+ /**
519
+ * Given a block name and attribute key, return true if the attribute is rich-text typed.
520
+ *
521
+ * @param blockName The name of the block, e.g. 'core/paragraph'.
522
+ * @param attributeName The name of the attribute to check, e.g. 'content'.
523
+ * @return True if the attribute is rich-text typed, false otherwise.
524
+ */
525
+ function isRichTextAttribute(
526
+ blockName: string,
527
+ attributeName: string
528
+ ): boolean {
529
+ return 'rich-text' === getBlockAttributeType( blockName, attributeName );
464
530
  }
465
531
 
466
532
  let localDoc: Y.Doc;
@@ -88,6 +88,41 @@ function convertYSelectionToBlockSelection(
88
88
  return null;
89
89
  }
90
90
 
91
+ /**
92
+ * Convert a YFullSelection to a WPSelection by resolving relative positions
93
+ * and verifying the blocks exist in the document.
94
+ * @param yFullSelection The YFullSelection to convert
95
+ * @param ydoc The Y.Doc to resolve positions against
96
+ * @return The converted WPSelection, or null if the conversion fails
97
+ */
98
+ function convertYFullSelectionToWPSelection(
99
+ yFullSelection: YFullSelection,
100
+ ydoc: Y.Doc
101
+ ): WPSelection | null {
102
+ const { start, end } = yFullSelection;
103
+ const startBlock = findBlockByClientIdInDoc( start.clientId, ydoc );
104
+ const endBlock = findBlockByClientIdInDoc( end.clientId, ydoc );
105
+
106
+ if ( ! startBlock || ! endBlock ) {
107
+ return null;
108
+ }
109
+
110
+ const startBlockSelection = convertYSelectionToBlockSelection(
111
+ start,
112
+ ydoc
113
+ );
114
+ const endBlockSelection = convertYSelectionToBlockSelection( end, ydoc );
115
+
116
+ if ( startBlockSelection === null || endBlockSelection === null ) {
117
+ return null;
118
+ }
119
+
120
+ return {
121
+ selectionStart: startBlockSelection,
122
+ selectionEnd: endBlockSelection,
123
+ };
124
+ }
125
+
91
126
  /**
92
127
  * Given a Y.Doc and a selection history, find the most recent selection
93
128
  * that exists in the document. Skip any selections that are not in the document.
@@ -99,34 +134,14 @@ function findSelectionFromHistory(
99
134
  ydoc: Y.Doc,
100
135
  selectionHistory: YFullSelection[]
101
136
  ): WPSelection | null {
102
- // Try each position until we find one that exists in the document
103
137
  for ( const positionToTry of selectionHistory ) {
104
- const { start, end } = positionToTry;
105
- const startBlock = findBlockByClientIdInDoc( start.clientId, ydoc );
106
- const endBlock = findBlockByClientIdInDoc( end.clientId, ydoc );
107
-
108
- if ( ! startBlock || ! endBlock ) {
109
- // This block no longer exists, skip it.
110
- continue;
111
- }
112
-
113
- const startBlockSelection = convertYSelectionToBlockSelection(
114
- start,
138
+ const result = convertYFullSelectionToWPSelection(
139
+ positionToTry,
115
140
  ydoc
116
141
  );
117
- const endBlockSelection = convertYSelectionToBlockSelection(
118
- end,
119
- ydoc
120
- );
121
-
122
- if ( startBlockSelection === null || endBlockSelection === null ) {
123
- continue;
142
+ if ( result !== null ) {
143
+ return result;
124
144
  }
125
-
126
- return {
127
- selectionStart: startBlockSelection,
128
- selectionEnd: endBlockSelection,
129
- };
130
145
  }
131
146
 
132
147
  return null;
@@ -205,3 +220,48 @@ export function restoreSelection(
205
220
  resetSelection( selectionEnd, selectionEnd, 0 );
206
221
  }
207
222
  }
223
+
224
+ /**
225
+ * If the latest selection has been shifted by remote edits, resolve and return
226
+ * it as a WPSelection. Returns null when the history is empty or neither
227
+ * endpoint has moved.
228
+ *
229
+ * @param ydoc The Y.Doc to resolve positions against
230
+ * @param selectionHistory The selection history to check
231
+ * @return The shifted WPSelection, or null if nothing moved.
232
+ */
233
+ export function getShiftedSelection(
234
+ ydoc: Y.Doc,
235
+ selectionHistory: YFullSelection[]
236
+ ): WPSelection | null {
237
+ if ( selectionHistory.length === 0 ) {
238
+ return null;
239
+ }
240
+
241
+ const { start, end } = selectionHistory[ 0 ];
242
+
243
+ // Block-level selections have no offset that can shift.
244
+ if (
245
+ start.type === YSelectionType.BlockSelection ||
246
+ end.type === YSelectionType.BlockSelection
247
+ ) {
248
+ return null;
249
+ }
250
+
251
+ const selectionStart = convertYSelectionToBlockSelection( start, ydoc );
252
+ const selectionEnd = convertYSelectionToBlockSelection( end, ydoc );
253
+
254
+ if ( ! selectionStart || ! selectionEnd ) {
255
+ return null;
256
+ }
257
+
258
+ // Only dispatch if at least one endpoint actually moved.
259
+ const startShifted = selectionStart.offset !== start.offset;
260
+ const endShifted = selectionEnd.offset !== end.offset;
261
+
262
+ if ( ! startShifted && ! endShifted ) {
263
+ return null;
264
+ }
265
+
266
+ return { selectionStart, selectionEnd };
267
+ }
package/src/utils/crdt.ts CHANGED
@@ -28,13 +28,13 @@ import {
28
28
  } from './crdt-blocks';
29
29
  import { type Post } from '../entity-types/post';
30
30
  import { type Type } from '../entity-types';
31
- import {
32
- CRDT_DOC_META_PERSISTENCE_KEY,
33
- CRDT_RECORD_MAP_KEY,
34
- WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
35
- } from '../sync';
31
+ import { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY } from '../sync';
36
32
  import type { WPSelection } from '../types';
37
- import { updateSelectionHistory } from './crdt-selection';
33
+ import {
34
+ getSelectionHistory,
35
+ getShiftedSelection,
36
+ updateSelectionHistory,
37
+ } from './crdt-selection';
38
38
  import {
39
39
  createYMap,
40
40
  getRootMap,
@@ -74,6 +74,8 @@ export interface YPostRecord extends YMapRecord {
74
74
  title: Y.Text;
75
75
  }
76
76
 
77
+ export const POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document';
78
+
77
79
  // Properties that are allowed to be synced for a post.
78
80
  const allowedPostProperties = new Set< string >( [
79
81
  'author',
@@ -97,7 +99,7 @@ const allowedPostProperties = new Set< string >( [
97
99
 
98
100
  // Post meta keys that should *not* be synced.
99
101
  const disallowedPostMetaKeys = new Set< string >( [
100
- WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
102
+ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
101
103
  ] );
102
104
 
103
105
  /**
@@ -416,6 +418,20 @@ export function getPostChangesFromCRDTDoc(
416
418
  };
417
419
  }
418
420
 
421
+ // When remote content changes are detected, recalculate the local user's
422
+ // selection using Y.RelativePosition to account for text shifts. The ydoc
423
+ // has already been updated with remote content at this point, so converting
424
+ // relative positions to absolute gives corrected offsets. Including the
425
+ // selection in PostChanges ensures it dispatches atomically with content.
426
+ const selectionHistory = getSelectionHistory( ydoc );
427
+ const shiftedSelection = getShiftedSelection( ydoc, selectionHistory );
428
+ if ( shiftedSelection ) {
429
+ changes.selection = {
430
+ ...shiftedSelection,
431
+ initialPosition: 0,
432
+ };
433
+ }
434
+
419
435
  return changes;
420
436
  }
421
437