@wordpress/core-data 7.45.0 → 7.45.1-next.v.202605131032.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 (181) hide show
  1. package/build/actions.cjs +8 -6
  2. package/build/actions.cjs.map +2 -2
  3. package/build/awareness/post-editor-awareness.cjs +1 -1
  4. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  5. package/build/resolvers.cjs +2 -1
  6. package/build/resolvers.cjs.map +2 -2
  7. package/build/types.cjs.map +2 -2
  8. package/build/utils/block-selection-history.cjs +4 -1
  9. package/build/utils/block-selection-history.cjs.map +2 -2
  10. package/build/utils/crdt-blocks.cjs +157 -89
  11. package/build/utils/crdt-blocks.cjs.map +2 -2
  12. package/build/utils/crdt-selection.cjs +1 -1
  13. package/build/utils/crdt-selection.cjs.map +2 -2
  14. package/build/utils/crdt-user-selections.cjs +4 -1
  15. package/build/utils/crdt-user-selections.cjs.map +2 -2
  16. package/build/utils/crdt-utils.cjs +18 -6
  17. package/build/utils/crdt-utils.cjs.map +2 -2
  18. package/build/utils/crdt.cjs +12 -2
  19. package/build/utils/crdt.cjs.map +2 -2
  20. package/build-module/actions.mjs +8 -6
  21. package/build-module/actions.mjs.map +2 -2
  22. package/build-module/awareness/post-editor-awareness.mjs +5 -2
  23. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  24. package/build-module/resolvers.mjs +2 -1
  25. package/build-module/resolvers.mjs.map +2 -2
  26. package/build-module/types.mjs.map +2 -2
  27. package/build-module/utils/block-selection-history.mjs +5 -1
  28. package/build-module/utils/block-selection-history.mjs.map +2 -2
  29. package/build-module/utils/crdt-blocks.mjs +162 -90
  30. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  31. package/build-module/utils/crdt-selection.mjs +2 -1
  32. package/build-module/utils/crdt-selection.mjs.map +2 -2
  33. package/build-module/utils/crdt-user-selections.mjs +9 -2
  34. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  35. package/build-module/utils/crdt-utils.mjs +16 -6
  36. package/build-module/utils/crdt-utils.mjs.map +2 -2
  37. package/build-module/utils/crdt.mjs +13 -2
  38. package/build-module/utils/crdt.mjs.map +2 -2
  39. package/build-types/actions.d.ts +177 -64
  40. package/build-types/actions.d.ts.map +1 -1
  41. package/build-types/awareness/awareness-state.d.ts.map +1 -1
  42. package/build-types/awareness/base-awareness.d.ts +0 -3
  43. package/build-types/awareness/base-awareness.d.ts.map +1 -1
  44. package/build-types/awareness/post-editor-awareness.d.ts +1 -8
  45. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  46. package/build-types/awareness/typed-awareness.d.ts.map +1 -1
  47. package/build-types/batch/create-batch.d.ts +1 -1
  48. package/build-types/batch/create-batch.d.ts.map +1 -1
  49. package/build-types/batch/default-processor.d.ts.map +1 -1
  50. package/build-types/batch/index.d.ts +2 -2
  51. package/build-types/batch/index.d.ts.map +1 -1
  52. package/build-types/entities.d.ts +114 -87
  53. package/build-types/entities.d.ts.map +1 -1
  54. package/build-types/entity-context.d.ts +1 -1
  55. package/build-types/entity-context.d.ts.map +1 -1
  56. package/build-types/entity-provider.d.ts +2 -2
  57. package/build-types/entity-provider.d.ts.map +1 -1
  58. package/build-types/entity-types/attachment.d.ts.map +1 -1
  59. package/build-types/entity-types/base-entity-records.d.ts.map +1 -1
  60. package/build-types/entity-types/base.d.ts.map +1 -1
  61. package/build-types/entity-types/comment.d.ts.map +1 -1
  62. package/build-types/entity-types/font-collection.d.ts.map +1 -1
  63. package/build-types/entity-types/font-family.d.ts.map +1 -1
  64. package/build-types/entity-types/global-styles-revision.d.ts.map +1 -1
  65. package/build-types/entity-types/icon.d.ts.map +1 -1
  66. package/build-types/entity-types/menu-location.d.ts.map +1 -1
  67. package/build-types/entity-types/nav-menu-item.d.ts.map +1 -1
  68. package/build-types/entity-types/nav-menu.d.ts.map +1 -1
  69. package/build-types/entity-types/page.d.ts.map +1 -1
  70. package/build-types/entity-types/plugin.d.ts.map +1 -1
  71. package/build-types/entity-types/post-revision.d.ts.map +1 -1
  72. package/build-types/entity-types/post-status.d.ts.map +1 -1
  73. package/build-types/entity-types/post.d.ts.map +1 -1
  74. package/build-types/entity-types/settings.d.ts.map +1 -1
  75. package/build-types/entity-types/sidebar.d.ts.map +1 -1
  76. package/build-types/entity-types/taxonomy.d.ts.map +1 -1
  77. package/build-types/entity-types/term.d.ts.map +1 -1
  78. package/build-types/entity-types/theme.d.ts.map +1 -1
  79. package/build-types/entity-types/type.d.ts.map +1 -1
  80. package/build-types/entity-types/user.d.ts.map +1 -1
  81. package/build-types/entity-types/widget-type.d.ts.map +1 -1
  82. package/build-types/entity-types/widget.d.ts.map +1 -1
  83. package/build-types/entity-types/wp-template-part.d.ts.map +1 -1
  84. package/build-types/entity-types/wp-template.d.ts.map +1 -1
  85. package/build-types/fetch/__experimental-fetch-url-data.d.ts +2 -5
  86. package/build-types/fetch/__experimental-fetch-url-data.d.ts.map +1 -1
  87. package/build-types/fetch/index.d.ts +3 -3
  88. package/build-types/fetch/index.d.ts.map +1 -1
  89. package/build-types/footnotes/get-footnotes-order.d.ts.map +1 -1
  90. package/build-types/footnotes/get-rich-text-values-cached.d.ts.map +1 -1
  91. package/build-types/footnotes/index.d.ts +1 -1
  92. package/build-types/footnotes/index.d.ts.map +1 -1
  93. package/build-types/hooks/use-entity-block-editor.d.ts +1 -1
  94. package/build-types/hooks/use-entity-block-editor.d.ts.map +1 -1
  95. package/build-types/hooks/use-entity-id.d.ts.map +1 -1
  96. package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
  97. package/build-types/hooks/use-resource-permissions.d.ts.map +1 -1
  98. package/build-types/index.d.ts +155 -153
  99. package/build-types/index.d.ts.map +1 -1
  100. package/build-types/locks/actions.d.ts +1 -1
  101. package/build-types/locks/actions.d.ts.map +1 -1
  102. package/build-types/locks/engine.d.ts +1 -1
  103. package/build-types/locks/engine.d.ts.map +1 -1
  104. package/build-types/locks/reducer.d.ts.map +1 -1
  105. package/build-types/locks/selectors.d.ts +2 -2
  106. package/build-types/locks/selectors.d.ts.map +1 -1
  107. package/build-types/locks/utils.d.ts +5 -5
  108. package/build-types/locks/utils.d.ts.map +1 -1
  109. package/build-types/name.d.ts +1 -1
  110. package/build-types/name.d.ts.map +1 -1
  111. package/build-types/private-actions.d.ts +45 -29
  112. package/build-types/private-actions.d.ts.map +1 -1
  113. package/build-types/private-apis.d.ts +1 -1
  114. package/build-types/private-apis.d.ts.map +1 -1
  115. package/build-types/queried-data/actions.d.ts +3 -3
  116. package/build-types/queried-data/actions.d.ts.map +1 -1
  117. package/build-types/queried-data/get-query-parts.d.ts +10 -34
  118. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  119. package/build-types/queried-data/index.d.ts +3 -3
  120. package/build-types/queried-data/index.d.ts.map +1 -1
  121. package/build-types/queried-data/reducer.d.ts +7 -23
  122. package/build-types/queried-data/reducer.d.ts.map +1 -1
  123. package/build-types/queried-data/selectors.d.ts +3 -3
  124. package/build-types/queried-data/selectors.d.ts.map +1 -1
  125. package/build-types/reducer.d.ts +40 -32
  126. package/build-types/reducer.d.ts.map +1 -1
  127. package/build-types/resolvers.d.ts +130 -47
  128. package/build-types/resolvers.d.ts.map +1 -1
  129. package/build-types/selectors.d.ts +1 -1
  130. package/build-types/selectors.d.ts.map +1 -1
  131. package/build-types/types.d.ts +61 -6
  132. package/build-types/types.d.ts.map +1 -1
  133. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  134. package/build-types/utils/conservative-map-item.d.ts.map +1 -1
  135. package/build-types/utils/crdt-blocks.d.ts +19 -9
  136. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  137. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  138. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  139. package/build-types/utils/crdt-utils.d.ts +35 -2
  140. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  141. package/build-types/utils/crdt.d.ts.map +1 -1
  142. package/build-types/utils/forward-resolver.d.ts +2 -2
  143. package/build-types/utils/forward-resolver.d.ts.map +1 -1
  144. package/build-types/utils/get-nested-value.d.ts.map +1 -1
  145. package/build-types/utils/get-normalized-comma-separable.d.ts +1 -1
  146. package/build-types/utils/get-normalized-comma-separable.d.ts.map +1 -1
  147. package/build-types/utils/if-matching-action.d.ts +3 -3
  148. package/build-types/utils/if-matching-action.d.ts.map +1 -1
  149. package/build-types/utils/index.d.ts +12 -12
  150. package/build-types/utils/index.d.ts.map +1 -1
  151. package/build-types/utils/is-numeric-id.d.ts.map +1 -1
  152. package/build-types/utils/log-entity-deprecation.d.ts +1 -1
  153. package/build-types/utils/log-entity-deprecation.d.ts.map +1 -1
  154. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -1
  155. package/build-types/utils/receive-intermediate-results.d.ts +1 -1
  156. package/build-types/utils/receive-intermediate-results.d.ts.map +1 -1
  157. package/build-types/utils/replace-action.d.ts +3 -3
  158. package/build-types/utils/replace-action.d.ts.map +1 -1
  159. package/build-types/utils/set-nested-value.d.ts.map +1 -1
  160. package/build-types/utils/user-permissions.d.ts +3 -3
  161. package/build-types/utils/user-permissions.d.ts.map +1 -1
  162. package/build-types/utils/with-weak-map-cache.d.ts +1 -1
  163. package/build-types/utils/with-weak-map-cache.d.ts.map +1 -1
  164. package/package.json +20 -20
  165. package/src/actions.js +7 -9
  166. package/src/awareness/post-editor-awareness.ts +5 -2
  167. package/src/resolvers.js +2 -1
  168. package/src/test/actions.js +58 -0
  169. package/src/test/resolvers.js +115 -2
  170. package/src/test/rtc-rich-text-offset-space.test.js +204 -0
  171. package/src/types.ts +63 -6
  172. package/src/utils/block-selection-history.ts +5 -1
  173. package/src/utils/crdt-blocks.ts +316 -116
  174. package/src/utils/crdt-selection.ts +2 -1
  175. package/src/utils/crdt-user-selections.ts +9 -2
  176. package/src/utils/crdt-utils.ts +53 -10
  177. package/src/utils/crdt.ts +30 -4
  178. package/src/utils/test/crdt-blocks.ts +74 -18
  179. package/src/utils/test/crdt-utils.ts +18 -2
  180. package/src/utils/test/rtc-rich-text-cursor-scope.test.js +267 -0
  181. package/src/utils/test/rtc-rich-text-offset-space.test.js +469 -0
@@ -14,9 +14,17 @@ import { Y } from '@wordpress/sync';
14
14
  /**
15
15
  * Internal dependencies
16
16
  */
17
- import { createYMap, type YMapRecord, type YMapWrap } from './crdt-utils';
17
+ import {
18
+ asRichTextOffset,
19
+ createYMap,
20
+ richTextOffsetToHtmlIndex,
21
+ type HtmlStringIndex,
22
+ type YMapRecord,
23
+ type YMapWrap,
24
+ } from './crdt-utils';
18
25
  import { getCachedRichTextData } from './crdt-text';
19
26
  import { Delta } from '../sync';
27
+ import { type WPBlockSelection } from '../types';
20
28
 
21
29
  interface BlockAttributes {
22
30
  [ key: string ]: unknown;
@@ -61,6 +69,14 @@ export type YBlocks = Y.Array< YBlock >;
61
69
  // Attribute values will be typed as the union of `Y.Text` and `unknown`.
62
70
  export type YBlockAttributes = Y.Map< Y.Text | unknown >;
63
71
 
72
+ /**
73
+ * Optional description of where a cursor falls.
74
+ *
75
+ * Used to coordinate shifting of cursor when applying changes
76
+ * to a Y.Doc with RichText instances.
77
+ */
78
+ export type MergeCursorPosition = WPBlockSelection | null;
79
+
64
80
  const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
65
81
 
66
82
  /**
@@ -110,10 +126,28 @@ function makeBlockAttributesSerializable(
110
126
  return newAttributes;
111
127
  }
112
128
 
129
+ /**
130
+ * Recursively removes properties which cannot be serialized from a list of block objects.
131
+ *
132
+ * @param blocks Eemove unserializable properties from each block object in this set.
133
+ * @return Copies of the provided blocks without the unserializable properties.
134
+ */
113
135
  function makeBlocksSerializable( blocks: Block[] ): Block[] {
114
136
  return blocks.map( ( block: Block ) => {
115
- const { name, innerBlocks, attributes, ...rest } = block;
116
- delete rest.validationIssues;
137
+ const {
138
+ name,
139
+ innerBlocks,
140
+ attributes,
141
+ /*
142
+ * Any validation issues discovered when loading a block are appended
143
+ * to the block node with a logging function, which cannot be serialized.
144
+ *
145
+ * @see import("@wordpress/blocks/src/api/parser").parseRawBlock()
146
+ */
147
+ validationIssues,
148
+ ...rest
149
+ } = block;
150
+
117
151
  return {
118
152
  ...rest,
119
153
  name,
@@ -381,14 +415,16 @@ function createNewYBlock( block: Block ): YBlock {
381
415
  * Merge incoming block data into the local Y.Doc.
382
416
  * This function is called to sync local block changes to a shared Y.Doc.
383
417
  *
384
- * @param yblocks The blocks in the local Y.Doc.
385
- * @param incomingBlocks Gutenberg blocks being synced.
386
- * @param cursorPosition The position of the cursor after the change occurs.
418
+ * @param yblocks The blocks in the local Y.Doc.
419
+ * @param incomingBlocks Gutenberg blocks being synced.
420
+ * @param attributeCursor When provided, describes a selection cursor falling within a
421
+ * RichText field associated with a specific block and attribute.
422
+ * Derived from the changes that produced the blocks.
387
423
  */
388
424
  export function mergeCrdtBlocks(
389
425
  yblocks: YBlocks,
390
426
  incomingBlocks: Block[],
391
- cursorPosition: number | null
427
+ attributeCursor: MergeCursorPosition
392
428
  ): void {
393
429
  // Ensure we are working with serializable block data.
394
430
  if ( ! serializableBlocksCache.has( incomingBlocks ) ) {
@@ -397,7 +433,9 @@ export function mergeCrdtBlocks(
397
433
  makeBlocksSerializable( incomingBlocks )
398
434
  );
399
435
  }
400
- const blocksToSync = serializableBlocksCache.get( incomingBlocks ) ?? [];
436
+
437
+ const incomingBlocksToSync =
438
+ serializableBlocksCache.get( incomingBlocks ) ?? [];
401
439
 
402
440
  // This is a rudimentary diff implementation similar to the y-prosemirror diffing
403
441
  // approach.
@@ -413,7 +451,7 @@ export function mergeCrdtBlocks(
413
451
  // @credit Kevin Jahns (dmonad)
414
452
  // @link https://github.com/WordPress/gutenberg/pull/68483
415
453
  const numOfCommonEntries = Math.min(
416
- blocksToSync.length ?? 0,
454
+ incomingBlocksToSync.length ?? 0,
417
455
  yblocks.length
418
456
  );
419
457
 
@@ -424,7 +462,7 @@ export function mergeCrdtBlocks(
424
462
  for (
425
463
  ;
426
464
  left < numOfCommonEntries &&
427
- areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) );
465
+ areBlocksEqual( incomingBlocksToSync[ left ], yblocks.get( left ) );
428
466
  left++
429
467
  ) {
430
468
  /* nop */
@@ -435,7 +473,7 @@ export function mergeCrdtBlocks(
435
473
  ;
436
474
  right < numOfCommonEntries - left &&
437
475
  areBlocksEqual(
438
- blocksToSync[ blocksToSync.length - right - 1 ],
476
+ incomingBlocksToSync[ incomingBlocksToSync.length - right - 1 ],
439
477
  yblocks.get( yblocks.length - right - 1 )
440
478
  );
441
479
  right++
@@ -446,108 +484,139 @@ export function mergeCrdtBlocks(
446
484
  const numOfUpdatesNeeded = numOfCommonEntries - left - right;
447
485
  const numOfInsertionsNeeded = Math.max(
448
486
  0,
449
- blocksToSync.length - yblocks.length
487
+ incomingBlocksToSync.length - yblocks.length
450
488
  );
451
489
  const numOfDeletionsNeeded = Math.max(
452
490
  0,
453
- yblocks.length - blocksToSync.length
491
+ yblocks.length - incomingBlocksToSync.length
454
492
  );
455
493
 
456
494
  // updates
457
495
  for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) {
458
- const block = blocksToSync[ left ];
459
- const yblock = yblocks.get( left );
460
-
461
- Object.entries( block ).forEach( ( [ key, value ] ) => {
462
- switch ( key ) {
463
- case 'attributes': {
464
- const currentAttributes = yblock.get( key );
496
+ const incomingYBlock = incomingBlocksToSync[ left ];
497
+ const localYBlock = yblocks.get( left );
465
498
 
466
- // If attributes are not set on the yblock, use the new values.
467
- if ( ! currentAttributes ) {
468
- yblock.set(
469
- key,
470
- createNewYAttributeMap( block.name, value )
499
+ Object.entries( incomingYBlock ).forEach(
500
+ ( [ incomingBlockProperty, incomingBlockPropertyValue ] ) => {
501
+ switch ( incomingBlockProperty ) {
502
+ case 'attributes': {
503
+ const localAttributes = localYBlock.get(
504
+ incomingBlockProperty
471
505
  );
472
- break;
473
- }
474
-
475
- Object.entries( value ).forEach(
476
- ( [ attributeName, attributeValue ] ) => {
477
- const currentAttribute =
478
- currentAttributes?.get( attributeName );
479
-
480
- const isExpectedType = isExpectedAttributeType(
481
- block.name,
482
- attributeName,
483
- currentAttribute
506
+ const incomingAttributes = incomingBlockPropertyValue;
507
+
508
+ // When the local block has no attributes, adopt the incoming set.
509
+ if ( ! localAttributes ) {
510
+ localYBlock.set(
511
+ incomingBlockProperty,
512
+ createNewYAttributeMap(
513
+ incomingYBlock.name,
514
+ incomingAttributes
515
+ )
484
516
  );
517
+ break;
518
+ }
485
519
 
486
- // Y types (Y.Text, Y.Array, Y.Map) cannot be
487
- // compared with fastDeepEqual against plain values.
488
- // Delegate to mergeYValue which handles no-op
489
- // detection at the edges.
490
- const isYType =
491
- currentAttribute instanceof Y.AbstractType;
492
-
493
- const isAttributeChanged =
494
- ! isExpectedType ||
495
- isYType ||
496
- ! fastDeepEqual(
497
- currentAttribute,
498
- attributeValue
520
+ // Otherwise the attributes need to be merged.
521
+ Object.entries( incomingAttributes ).forEach(
522
+ ( [
523
+ incomingAttributeName,
524
+ incomingAttributeValue,
525
+ ] ) => {
526
+ const currentAttribute = localAttributes?.get(
527
+ incomingAttributeName
499
528
  );
500
529
 
501
- if ( isAttributeChanged ) {
502
- updateYBlockAttribute(
503
- block.name,
504
- attributeName,
505
- attributeValue,
506
- currentAttributes,
507
- cursorPosition
530
+ const isExpectedType = isExpectedAttributeType(
531
+ incomingYBlock.name,
532
+ incomingAttributeName,
533
+ currentAttribute
508
534
  );
535
+
536
+ // Y types (Y.Text, Y.Array, Y.Map) cannot be
537
+ // compared with fastDeepEqual against plain values.
538
+ // Delegate to mergeYValue which handles no-op
539
+ // detection at the edges.
540
+ const isYType =
541
+ currentAttribute instanceof Y.AbstractType;
542
+
543
+ const isAttributeChanged =
544
+ ! isExpectedType ||
545
+ isYType ||
546
+ ! fastDeepEqual(
547
+ currentAttribute,
548
+ incomingAttributeValue
549
+ );
550
+
551
+ if ( isAttributeChanged ) {
552
+ updateYBlockAttribute(
553
+ incomingYBlock.name,
554
+ incomingYBlock.clientId,
555
+ incomingAttributeName,
556
+ incomingAttributeValue,
557
+ localAttributes,
558
+ attributeCursor
559
+ );
560
+ }
509
561
  }
510
- }
511
- );
562
+ );
512
563
 
513
- // Delete any attributes that are no longer present.
514
- currentAttributes.forEach(
515
- ( _attrValue: unknown, attrName: string ) => {
516
- if ( ! value.hasOwnProperty( attrName ) ) {
517
- currentAttributes.delete( attrName );
564
+ // Delete any attributes that are no longer present.
565
+ localAttributes.forEach(
566
+ ( _attrValue: unknown, attrName: string ) => {
567
+ if (
568
+ ! incomingBlockPropertyValue.hasOwnProperty(
569
+ attrName
570
+ )
571
+ ) {
572
+ localAttributes.delete( attrName );
573
+ }
518
574
  }
519
- }
520
- );
575
+ );
521
576
 
522
- break;
523
- }
577
+ break;
578
+ }
524
579
 
525
- case 'innerBlocks': {
526
- // Recursively merge innerBlocks
527
- let yInnerBlocks = yblock.get( key );
580
+ case 'innerBlocks': {
581
+ // Recursively merge innerBlocks
582
+ let yInnerBlocks = localYBlock.get(
583
+ incomingBlockProperty
584
+ );
528
585
 
529
- if ( ! ( yInnerBlocks instanceof Y.Array ) ) {
530
- yInnerBlocks = new Y.Array< YBlock >();
531
- yblock.set( key, yInnerBlocks );
586
+ if ( ! ( yInnerBlocks instanceof Y.Array ) ) {
587
+ yInnerBlocks = new Y.Array< YBlock >();
588
+ localYBlock.set(
589
+ incomingBlockProperty,
590
+ yInnerBlocks
591
+ );
592
+ }
593
+
594
+ mergeCrdtBlocks(
595
+ yInnerBlocks,
596
+ incomingBlockPropertyValue ?? [],
597
+ attributeCursor
598
+ );
599
+ break;
532
600
  }
533
601
 
534
- mergeCrdtBlocks(
535
- yInnerBlocks,
536
- value ?? [],
537
- cursorPosition
538
- );
539
- break;
602
+ default:
603
+ if (
604
+ ! fastDeepEqual(
605
+ incomingYBlock[ incomingBlockProperty ],
606
+ localYBlock.get( incomingBlockProperty )
607
+ )
608
+ ) {
609
+ localYBlock.set(
610
+ incomingBlockProperty,
611
+ incomingBlockPropertyValue
612
+ );
613
+ }
540
614
  }
541
-
542
- default:
543
- if ( ! fastDeepEqual( block[ key ], yblock.get( key ) ) ) {
544
- yblock.set( key, value );
545
- }
546
615
  }
547
- } );
548
- yblock.forEach( ( _v, k ) => {
549
- if ( ! block.hasOwnProperty( k ) ) {
550
- yblock.delete( k );
616
+ );
617
+ localYBlock.forEach( ( _v, k ) => {
618
+ if ( ! incomingYBlock.hasOwnProperty( k ) ) {
619
+ localYBlock.delete( k );
551
620
  }
552
621
  } );
553
622
  }
@@ -557,7 +626,7 @@ export function mergeCrdtBlocks(
557
626
 
558
627
  // inserts
559
628
  for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) {
560
- const newBlock = [ createNewYBlock( blocksToSync[ left ] ) ];
629
+ const newBlock = [ createNewYBlock( incomingBlocksToSync[ left ] ) ];
561
630
 
562
631
  yblocks.insert( left, newBlock );
563
632
  }
@@ -613,12 +682,14 @@ function areArrayElementsEqual(
613
682
  * @param newValue The new plain array to merge into the Y.Array.
614
683
  * @param schema The attribute schema (must have `query`).
615
684
  * @param cursorPosition The local cursor position for rich-text delta merges.
685
+ * @param cursorScope The selected block attribute scope for rich-text cursor hints.
616
686
  */
617
687
  function mergeYArray(
618
688
  yArray: Y.Array< unknown >,
619
689
  newValue: unknown[],
620
690
  schema: BlockAttributeSchema,
621
- cursorPosition: number | null
691
+ cursorPosition: MergeCursorPosition,
692
+ cursorScope: RichTextCursorScope
622
693
  ): void {
623
694
  if ( ! schema.query ) {
624
695
  return;
@@ -665,7 +736,8 @@ function mergeYArray(
665
736
  currentElement,
666
737
  newElement,
667
738
  query,
668
- cursorPosition
739
+ cursorPosition,
740
+ cursorScope
669
741
  );
670
742
  } else {
671
743
  // Element is the wrong type (e.g. partial migration) or the
@@ -721,14 +793,17 @@ function mergeYArray(
721
793
  * @param newVal The new value to merge into the Y.Map entry.
722
794
  * @param yMap The Y.Map that owns this entry.
723
795
  * @param key The key of this entry in the Y.Map.
724
- * @param cursorPosition The local cursor position for rich-text delta merges.
796
+ * @param cursorPosition The cursor position for rich-text delta merges from the updated value.
797
+ * @param cursorScope Indicates a specific block and attribute associated with the editor;
798
+ * determines whether the cursor should be updated based on the change.
725
799
  */
726
800
  function mergeYValue(
727
801
  schema: BlockAttributeSchema | undefined,
728
802
  newVal: unknown,
729
803
  yMap: Y.Map< unknown >,
730
804
  key: string,
731
- cursorPosition: number | null
805
+ cursorPosition: MergeCursorPosition,
806
+ cursorScope: RichTextCursorScope
732
807
  ): void {
733
808
  const currentVal = yMap.get( key );
734
809
  if (
@@ -736,21 +811,31 @@ function mergeYValue(
736
811
  typeof newVal === 'string' &&
737
812
  currentVal instanceof Y.Text
738
813
  ) {
739
- mergeRichTextUpdate( currentVal, newVal, cursorPosition );
814
+ mergeRichTextUpdate(
815
+ currentVal,
816
+ newVal,
817
+ resolveRichTextCursorPosition( cursorPosition, cursorScope, newVal )
818
+ );
740
819
  } else if (
741
820
  schema?.type === 'array' &&
742
821
  schema.query &&
743
822
  Array.isArray( newVal ) &&
744
823
  currentVal instanceof Y.Array
745
824
  ) {
746
- mergeYArray( currentVal, newVal, schema, cursorPosition );
825
+ mergeYArray( currentVal, newVal, schema, cursorPosition, cursorScope );
747
826
  } else if (
748
827
  schema?.type === 'object' &&
749
828
  schema.query &&
750
829
  isRecord( newVal ) &&
751
830
  currentVal instanceof Y.Map
752
831
  ) {
753
- mergeYMapValues( currentVal, newVal, schema.query, cursorPosition );
832
+ mergeYMapValues(
833
+ currentVal,
834
+ newVal,
835
+ schema.query,
836
+ cursorPosition,
837
+ cursorScope
838
+ );
754
839
  } else {
755
840
  const newYValue = createYValueFromSchema( schema, newVal );
756
841
 
@@ -773,15 +858,24 @@ function mergeYValue(
773
858
  * @param newObj The new plain object to merge into the Y.Map.
774
859
  * @param query The query schema defining property types.
775
860
  * @param cursorPosition The local cursor position for rich-text delta merges.
861
+ * @param cursorScope The selected block attribute scope for rich-text cursor hints.
776
862
  */
777
863
  function mergeYMapValues(
778
864
  yMap: Y.Map< unknown >,
779
865
  newObj: Record< string, unknown >,
780
866
  query: Record< string, BlockAttributeSchema >,
781
- cursorPosition: number | null
867
+ cursorPosition: MergeCursorPosition,
868
+ cursorScope: RichTextCursorScope
782
869
  ): void {
783
870
  for ( const [ key, newVal ] of Object.entries( newObj ) ) {
784
- mergeYValue( query[ key ], newVal, yMap, key, cursorPosition );
871
+ mergeYValue(
872
+ query[ key ],
873
+ newVal,
874
+ yMap,
875
+ key,
876
+ cursorPosition,
877
+ cursorScope
878
+ );
785
879
  }
786
880
 
787
881
  // Delete properties absent from the incoming object.
@@ -796,29 +890,94 @@ function mergeYMapValues(
796
890
  * Update a single attribute on a Yjs block attributes map (currentAttributes).
797
891
  *
798
892
  * @param blockName The block type name, e.g. 'core/paragraph'.
893
+ * @param clientId The local clientId for the block being merged.
799
894
  * @param attributeName The name of the attribute to update, e.g. 'content'.
800
895
  * @param attributeValue The new value for the attribute.
801
896
  * @param currentAttributes The Y.Map holding the block's current attributes.
802
- * @param cursorPosition The local cursor position, used when merging rich-text deltas.
897
+ * @param newCursorPosition The cursor position for rich-text delta merges from the updated value.
898
+ * Notably, this may not correspond to the attribute being edited and is
899
+ * used to determine if any cursors need shifting in response to the change.
803
900
  */
804
901
  function updateYBlockAttribute(
805
902
  blockName: string,
903
+ clientId: string | undefined,
806
904
  attributeName: string,
807
905
  attributeValue: unknown,
808
906
  currentAttributes: YBlockAttributes,
809
- cursorPosition: number | null
907
+ newCursorPosition: MergeCursorPosition
810
908
  ): void {
811
909
  const schema = getBlockAttributeSchema( blockName, attributeName );
812
910
 
911
+ /*
912
+ * @todo There is a slight discrepancy between the attribute name and key, which might
913
+ * show up when working with multiline RichText instances (of which there are no
914
+ * more within Core). For those instances, a cursor might never be updated in
915
+ * response to changes because its `attributeKey` won’t match any of the block’s
916
+ * attribute names, and since updating this attribute is based on the block names,
917
+ * no suitable key for the cursor scope will be created. To fix, the updating code
918
+ * would need to parse multiline attributes and infer the `attributeKey` being updated.
919
+ */
813
920
  mergeYValue(
814
921
  schema,
815
922
  attributeValue,
816
923
  currentAttributes,
817
924
  attributeName,
818
- cursorPosition
925
+ newCursorPosition,
926
+ { attributeKey: attributeName, clientId }
819
927
  );
820
928
  }
821
929
 
930
+ /**
931
+ * References the specific block and attribute associated with a RichText component.
932
+ *
933
+ * This is used to associate a cursor with the attribute it’s editing.
934
+ *
935
+ * @see WPBlockSelection
936
+ */
937
+ interface RichTextCursorScope {
938
+ attributeKey: string;
939
+ clientId: string | undefined;
940
+ }
941
+
942
+ interface DeltaWithOps {
943
+ ops: Parameters< Y.Text[ 'applyDelta' ] >[ 0 ];
944
+ }
945
+
946
+ /**
947
+ * When the provided cursor falls within the given block and attribute’s scope,
948
+ * returns an index into the RichText’s serialized HTML where the cursor falls.
949
+ *
950
+ * The cursor scope constrains resolution to ensure that indices are only reported
951
+ * when a cursor falls within the block and attribute being updated, since the
952
+ * attributes being updated may not always be the ones where a cursor presently falls.
953
+ *
954
+ * Returned index measures JS string lengths, thus is counted in UTF-16 code units
955
+ * and contains the syntax characters making up HTML tags, comments, character
956
+ * references, and other non-plaintext content.
957
+ *
958
+ * @param cursorPosition Description of the cursor in the new value.
959
+ * @param cursorScope Cursors should only be updated if they fall within this
960
+ * specific block and attribute.
961
+ * @param updatedValue New RichText value potentially containing cursor.
962
+ * @return String length into serialized HTML for RichText instance where cursor falls.
963
+ */
964
+ function resolveRichTextCursorPosition(
965
+ cursorPosition: MergeCursorPosition,
966
+ cursorScope: RichTextCursorScope,
967
+ updatedValue: string
968
+ ): HtmlStringIndex | null {
969
+ return cursorPosition &&
970
+ cursorPosition.clientId === cursorScope.clientId &&
971
+ cursorPosition.attributeKey === cursorScope.attributeKey &&
972
+ 'number' === typeof cursorPosition.offset &&
973
+ Number.isInteger( cursorPosition.offset )
974
+ ? richTextOffsetToHtmlIndex(
975
+ updatedValue,
976
+ asRichTextOffset( cursorPosition.offset )
977
+ )
978
+ : null;
979
+ }
980
+
822
981
  // Cached block attribute types, populated once from getBlockTypes().
823
982
  let cachedBlockAttributeSchemas: Map<
824
983
  string,
@@ -912,14 +1071,14 @@ let localDoc: Y.Doc;
912
1071
  * Given a Y.Text object and an updated string value, diff the new value and
913
1072
  * apply the delta to the Y.Text.
914
1073
  *
915
- * @param blockYText The Y.Text to update.
916
- * @param updatedValue The updated value.
917
- * @param cursorPosition The position of the cursor after the change occurs.
1074
+ * @param blockYText The Y.Text to update.
1075
+ * @param updatedValue The updated value.
1076
+ * @param htmlCursorIndex The cursor index in the updated HTML string.
918
1077
  */
919
1078
  export function mergeRichTextUpdate(
920
1079
  blockYText: Y.Text,
921
1080
  updatedValue: string,
922
- cursorPosition: number | null = null
1081
+ htmlCursorIndex: HtmlStringIndex | null = null
923
1082
  ): void {
924
1083
  // Gutenberg does not use Yjs shared types natively, so we can only subscribe
925
1084
  // to changes from store and apply them to Yjs types that we create and
@@ -930,22 +1089,63 @@ export function mergeRichTextUpdate(
930
1089
  // The code below allows us to compute a delta between the current and new
931
1090
  // value, then apply it to the Y.Text.
932
1091
 
1092
+ const currentValueAsDelta = new Delta( blockYText.toDelta() );
1093
+ const updatedValueAsDelta = new Delta( [ { insert: updatedValue } ] );
1094
+ const deltaDiff = currentValueAsDelta.diffWithCursor(
1095
+ updatedValueAsDelta,
1096
+ htmlCursorIndex
1097
+ );
1098
+
1099
+ /**
1100
+ * When there is no cursor involved, or when the diff is able to shuffle properly
1101
+ * around the cursor then apply that already-computed diff.
1102
+ *
1103
+ * However, `diffWithCursor()` currently fails in certain cases, producing corrupted
1104
+ * output. In these cases, fall back to the raw diff as that will apply cleanly,
1105
+ * even if it provides a less meaningful diff.
1106
+ *
1107
+ * @see Delta.diffWithCursor()
1108
+ */
1109
+ const safeDiff =
1110
+ htmlCursorIndex === null ||
1111
+ isDeltaVerificationMatch( blockYText, deltaDiff, updatedValue )
1112
+ ? deltaDiff
1113
+ : currentValueAsDelta.diff( updatedValueAsDelta );
1114
+
1115
+ blockYText.applyDelta( safeDiff.ops );
1116
+ }
1117
+
1118
+ /**
1119
+ * Verify that applying a delta to an existing Y.Text object produces the expected
1120
+ * output string.
1121
+ *
1122
+ * A stale, mis-scoped, or corrupted Delta will mutate a text value to the wrong
1123
+ * output string. This function applies the given Delta and indicates whether it
1124
+ * produces the given expected output string value.
1125
+ *
1126
+ * @param blockYText The current Y.Text before applying the candidate delta.
1127
+ * @param delta The candidate delta.
1128
+ * @param expectedValue The exact string expected after applying the delta.
1129
+ * @return Whether the candidate delta produces the expected value.
1130
+ */
1131
+ function isDeltaVerificationMatch(
1132
+ blockYText: Y.Text,
1133
+ delta: DeltaWithOps,
1134
+ expectedValue: string
1135
+ ): boolean {
933
1136
  if ( ! localDoc ) {
934
1137
  // Y.Text must be attached to a Y.Doc to be able to do operations on it.
935
1138
  // Create a temporary Y.Text attached to a local Y.Doc for delta computation.
1139
+ // This is an optimization to avoid creating a new Y.Doc on every update.
936
1140
  localDoc = new Y.Doc();
937
1141
  }
938
1142
 
939
- const localYText = localDoc.getText( 'temporary-text' );
940
- localYText.delete( 0, localYText.length );
941
- localYText.insert( 0, updatedValue );
1143
+ const verificationYText = localDoc.getText( 'verification-text' );
942
1144
 
943
- const currentValueAsDelta = new Delta( blockYText.toDelta() );
944
- const updatedValueAsDelta = new Delta( localYText.toDelta() );
945
- const deltaDiff = currentValueAsDelta.diffWithCursor(
946
- updatedValueAsDelta,
947
- cursorPosition
948
- );
1145
+ // Because this is global, it must be cleared before using.
1146
+ verificationYText.delete( 0, verificationYText.length );
1147
+ verificationYText.insert( 0, blockYText.toString() );
1148
+ verificationYText.applyDelta( delta.ops );
949
1149
 
950
- blockYText.applyDelta( deltaDiff.ops );
1150
+ return verificationYText.toString() === expectedValue;
951
1151
  }
@@ -18,6 +18,7 @@ import {
18
18
  type YSelection,
19
19
  } from './block-selection-history';
20
20
  import {
21
+ asHtmlStringIndex,
21
22
  findBlockByClientIdInDoc,
22
23
  htmlIndexToRichTextOffset,
23
24
  } from './crdt-utils';
@@ -78,7 +79,7 @@ function convertYSelectionToBlockSelection(
78
79
  attributeKey,
79
80
  offset: htmlIndexToRichTextOffset(
80
81
  absolutePosition.type.toString(),
81
- absolutePosition.index
82
+ asHtmlStringIndex( absolutePosition.index )
82
83
  ),
83
84
  };
84
85
  }
@@ -12,7 +12,11 @@ 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, richTextOffsetToHtmlIndex } from './crdt-utils';
15
+ import {
16
+ asRichTextOffset,
17
+ getRootMap,
18
+ richTextOffsetToHtmlIndex,
19
+ } from './crdt-utils';
16
20
  import type {
17
21
  AbsoluteBlockIndexPath,
18
22
  WPBlockSelection,
@@ -178,7 +182,10 @@ function getCursorPosition(
178
182
 
179
183
  const relativePosition = Y.createRelativePositionFromTypeIndex(
180
184
  currentYText,
181
- richTextOffsetToHtmlIndex( currentYText.toString(), selection.offset )
185
+ richTextOffsetToHtmlIndex(
186
+ currentYText.toString(),
187
+ asRichTextOffset( selection.offset )
188
+ )
182
189
  );
183
190
 
184
191
  return {