@wordpress/core-data 7.40.2-next.v.202602241322.0 → 7.40.2

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 (183) hide show
  1. package/README.md +0 -27
  2. package/build/actions.cjs +9 -26
  3. package/build/actions.cjs.map +2 -2
  4. package/build/awareness/post-editor-awareness.cjs +45 -6
  5. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  6. package/build/awareness/types.cjs.map +1 -1
  7. package/build/entities.cjs +50 -23
  8. package/build/entities.cjs.map +2 -2
  9. package/build/entity-provider.cjs +15 -6
  10. package/build/entity-provider.cjs.map +2 -2
  11. package/build/hooks/use-entity-prop.cjs +33 -2
  12. package/build/hooks/use-entity-prop.cjs.map +2 -2
  13. package/build/hooks/use-post-editor-awareness-state.cjs +120 -1
  14. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  15. package/build/index.cjs +0 -3
  16. package/build/index.cjs.map +2 -2
  17. package/build/private-actions.cjs +27 -3
  18. package/build/private-actions.cjs.map +2 -2
  19. package/build/private-apis.cjs +14 -5
  20. package/build/private-apis.cjs.map +3 -3
  21. package/build/private-selectors.cjs +22 -2
  22. package/build/private-selectors.cjs.map +2 -2
  23. package/build/queried-data/actions.cjs +1 -1
  24. package/build/queried-data/actions.cjs.map +2 -2
  25. package/build/queried-data/reducer.cjs +19 -13
  26. package/build/queried-data/reducer.cjs.map +2 -2
  27. package/build/reducer.cjs +19 -2
  28. package/build/reducer.cjs.map +2 -2
  29. package/build/resolvers.cjs +16 -6
  30. package/build/resolvers.cjs.map +2 -2
  31. package/build/selectors.cjs +0 -15
  32. package/build/selectors.cjs.map +2 -2
  33. package/build/sync.cjs +11 -5
  34. package/build/sync.cjs.map +2 -2
  35. package/build/types.cjs.map +1 -1
  36. package/build/utils/block-selection-history.cjs +1 -1
  37. package/build/utils/block-selection-history.cjs.map +2 -2
  38. package/build/utils/crdt-blocks.cjs +125 -49
  39. package/build/utils/crdt-blocks.cjs.map +2 -2
  40. package/build/utils/crdt-selection.cjs +50 -19
  41. package/build/utils/crdt-selection.cjs.map +2 -2
  42. package/build/utils/crdt-text.cjs +52 -0
  43. package/build/utils/crdt-text.cjs.map +7 -0
  44. package/build/utils/crdt-user-selections.cjs +16 -6
  45. package/build/utils/crdt-user-selections.cjs.map +3 -3
  46. package/build/utils/crdt-utils.cjs +54 -2
  47. package/build/utils/crdt-utils.cjs.map +2 -2
  48. package/build/utils/crdt.cjs +38 -29
  49. package/build/utils/crdt.cjs.map +2 -2
  50. package/build-module/actions.mjs +14 -26
  51. package/build-module/actions.mjs.map +2 -2
  52. package/build-module/awareness/post-editor-awareness.mjs +47 -7
  53. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  54. package/build-module/entities.mjs +53 -24
  55. package/build-module/entities.mjs.map +2 -2
  56. package/build-module/entity-provider.mjs +15 -6
  57. package/build-module/entity-provider.mjs.map +2 -2
  58. package/build-module/hooks/use-entity-prop.mjs +34 -3
  59. package/build-module/hooks/use-entity-prop.mjs.map +2 -2
  60. package/build-module/hooks/use-post-editor-awareness-state.mjs +117 -1
  61. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  62. package/build-module/index.mjs +0 -2
  63. package/build-module/index.mjs.map +2 -2
  64. package/build-module/private-actions.mjs +24 -2
  65. package/build-module/private-actions.mjs.map +2 -2
  66. package/build-module/private-apis.mjs +21 -6
  67. package/build-module/private-apis.mjs.map +3 -3
  68. package/build-module/private-selectors.mjs +19 -1
  69. package/build-module/private-selectors.mjs.map +2 -2
  70. package/build-module/queried-data/actions.mjs +1 -1
  71. package/build-module/queried-data/actions.mjs.map +2 -2
  72. package/build-module/queried-data/reducer.mjs +19 -13
  73. package/build-module/queried-data/reducer.mjs.map +2 -2
  74. package/build-module/reducer.mjs +18 -2
  75. package/build-module/reducer.mjs.map +2 -2
  76. package/build-module/resolvers.mjs +16 -6
  77. package/build-module/resolvers.mjs.map +2 -2
  78. package/build-module/selectors.mjs +0 -14
  79. package/build-module/selectors.mjs.map +2 -2
  80. package/build-module/sync.mjs +7 -3
  81. package/build-module/sync.mjs.map +2 -2
  82. package/build-module/utils/block-selection-history.mjs +5 -2
  83. package/build-module/utils/block-selection-history.mjs.map +2 -2
  84. package/build-module/utils/crdt-blocks.mjs +124 -49
  85. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  86. package/build-module/utils/crdt-selection.mjs +53 -20
  87. package/build-module/utils/crdt-selection.mjs.map +2 -2
  88. package/build-module/utils/crdt-text.mjs +26 -0
  89. package/build-module/utils/crdt-text.mjs.map +7 -0
  90. package/build-module/utils/crdt-user-selections.mjs +16 -7
  91. package/build-module/utils/crdt-user-selections.mjs.map +3 -3
  92. package/build-module/utils/crdt-utils.mjs +51 -1
  93. package/build-module/utils/crdt-utils.mjs.map +2 -2
  94. package/build-module/utils/crdt.mjs +42 -34
  95. package/build-module/utils/crdt.mjs.map +2 -2
  96. package/build-types/actions.d.ts +0 -11
  97. package/build-types/actions.d.ts.map +1 -1
  98. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  99. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  100. package/build-types/awareness/types.d.ts +6 -1
  101. package/build-types/awareness/types.d.ts.map +1 -1
  102. package/build-types/entities.d.ts +20 -1
  103. package/build-types/entities.d.ts.map +1 -1
  104. package/build-types/entity-provider.d.ts +11 -6
  105. package/build-types/entity-provider.d.ts.map +1 -1
  106. package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
  107. package/build-types/hooks/use-post-editor-awareness-state.d.ts +40 -7
  108. package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
  109. package/build-types/index.d.ts +0 -3
  110. package/build-types/index.d.ts.map +1 -1
  111. package/build-types/parsed-blocks-cache.d.ts +10 -0
  112. package/build-types/parsed-blocks-cache.d.ts.map +1 -0
  113. package/build-types/private-actions.d.ts +12 -0
  114. package/build-types/private-actions.d.ts.map +1 -1
  115. package/build-types/private-apis.d.ts +21 -1
  116. package/build-types/private-apis.d.ts.map +1 -1
  117. package/build-types/private-selectors.d.ts +17 -0
  118. package/build-types/private-selectors.d.ts.map +1 -1
  119. package/build-types/queried-data/reducer.d.ts.map +1 -1
  120. package/build-types/reducer.d.ts +15 -0
  121. package/build-types/reducer.d.ts.map +1 -1
  122. package/build-types/resolvers.d.ts.map +1 -1
  123. package/build-types/selectors.d.ts +1 -9
  124. package/build-types/selectors.d.ts.map +1 -1
  125. package/build-types/sync.d.ts +2 -2
  126. package/build-types/sync.d.ts.map +1 -1
  127. package/build-types/types.d.ts +11 -2
  128. package/build-types/types.d.ts.map +1 -1
  129. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  130. package/build-types/utils/crdt-blocks.d.ts +12 -1
  131. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  132. package/build-types/utils/crdt-selection.d.ts +10 -0
  133. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  134. package/build-types/utils/crdt-text.d.ts +16 -0
  135. package/build-types/utils/crdt-text.d.ts.map +1 -0
  136. package/build-types/utils/crdt-user-selections.d.ts +17 -4
  137. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  138. package/build-types/utils/crdt-utils.d.ts +20 -0
  139. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  140. package/build-types/utils/crdt.d.ts +14 -7
  141. package/build-types/utils/crdt.d.ts.map +1 -1
  142. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  143. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  144. package/build-types/utils/save-crdt-doc.d.ts +8 -0
  145. package/build-types/utils/save-crdt-doc.d.ts.map +1 -0
  146. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  147. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  148. package/package.json +18 -18
  149. package/src/actions.js +26 -41
  150. package/src/awareness/post-editor-awareness.ts +106 -7
  151. package/src/awareness/test/post-editor-awareness.ts +50 -10
  152. package/src/awareness/types.ts +7 -1
  153. package/src/entities.js +62 -24
  154. package/src/entity-provider.js +24 -11
  155. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  156. package/src/hooks/use-entity-prop.js +43 -3
  157. package/src/hooks/use-post-editor-awareness-state.ts +229 -7
  158. package/src/index.js +0 -6
  159. package/src/private-actions.js +43 -1
  160. package/src/{private-apis.js → private-apis.ts} +21 -3
  161. package/src/private-selectors.ts +43 -0
  162. package/src/queried-data/actions.js +1 -1
  163. package/src/queried-data/reducer.js +26 -14
  164. package/src/reducer.js +36 -1
  165. package/src/resolvers.js +23 -7
  166. package/src/selectors.ts +1 -32
  167. package/src/sync.ts +6 -2
  168. package/src/test/entities.js +231 -14
  169. package/src/test/private-actions.js +1 -1
  170. package/src/test/resolvers.js +64 -11
  171. package/src/test/store.js +116 -0
  172. package/src/types.ts +21 -2
  173. package/src/utils/block-selection-history.ts +5 -2
  174. package/src/utils/crdt-blocks.ts +250 -81
  175. package/src/utils/crdt-selection.ts +92 -26
  176. package/src/utils/crdt-text.ts +43 -0
  177. package/src/utils/crdt-user-selections.ts +29 -8
  178. package/src/utils/crdt-utils.ts +99 -0
  179. package/src/utils/crdt.ts +72 -46
  180. package/src/utils/test/crdt-blocks.ts +1177 -22
  181. package/src/utils/test/crdt-user-selections.ts +5 -0
  182. package/src/utils/test/crdt-utils.ts +387 -0
  183. package/src/utils/test/crdt.ts +423 -60
@@ -16,15 +16,22 @@ import { Y } from '@wordpress/sync';
16
16
  * Internal dependencies
17
17
  */
18
18
  import { createYMap, type YMapRecord, type YMapWrap } from './crdt-utils';
19
+ import { getCachedRichTextData } from './crdt-text';
19
20
  import { Delta } from '../sync';
20
21
 
21
22
  interface BlockAttributes {
22
23
  [ key: string ]: unknown;
23
24
  }
24
25
 
26
+ interface BlockAttributeType {
27
+ role?: string;
28
+ type?: string;
29
+ query?: Record< string, BlockAttributeType >;
30
+ }
31
+
25
32
  interface BlockType {
33
+ attributes?: Record< string, BlockAttributeType >;
26
34
  name: string;
27
- attributes?: Record< string, { type?: string } >;
28
35
  }
29
36
 
30
37
  // A block as represented in Gutenberg's data store.
@@ -39,7 +46,7 @@ export interface Block {
39
46
  }
40
47
 
41
48
  // A block as represented in the CRDT document (Y.Map).
42
- interface YBlockRecord extends YMapRecord {
49
+ export interface YBlockRecord extends YMapRecord {
43
50
  attributes: YBlockAttributes;
44
51
  clientId: string;
45
52
  innerBlocks: YBlocks;
@@ -57,14 +64,49 @@ export type YBlockAttributes = Y.Map< Y.Text | unknown >;
57
64
 
58
65
  const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
59
66
 
67
+ /**
68
+ * Recursively walk an attribute value and convert any RichTextData instances
69
+ * to their string (HTML) representation. This is necessary for array-type and
70
+ * object-type attributes, which can contain nested RichTextData.
71
+ *
72
+ * @param value The attribute value to serialize.
73
+ * @return The value with all RichTextData instances replaced by strings.
74
+ */
75
+ function serializeAttributeValue( value: unknown ): unknown {
76
+ if ( value instanceof RichTextData ) {
77
+ return value.valueOf();
78
+ }
79
+
80
+ // e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
81
+ if ( Array.isArray( value ) ) {
82
+ return value.map( serializeAttributeValue );
83
+ }
84
+
85
+ // e.g. a single row inside core/table `body`: { cells: [ ... ] }
86
+ if ( value && typeof value === 'object' ) {
87
+ const result: Record< string, unknown > = {};
88
+
89
+ for ( const [ k, v ] of Object.entries( value ) ) {
90
+ result[ k ] = serializeAttributeValue( v );
91
+ }
92
+ return result;
93
+ }
94
+
95
+ return value;
96
+ }
97
+
60
98
  function makeBlockAttributesSerializable(
99
+ blockName: string,
61
100
  attributes: BlockAttributes
62
101
  ): BlockAttributes {
63
102
  const newAttributes = { ...attributes };
64
103
  for ( const [ key, value ] of Object.entries( attributes ) ) {
65
- if ( value instanceof RichTextData ) {
66
- newAttributes[ key ] = value.valueOf();
104
+ if ( isLocalAttribute( blockName, key ) ) {
105
+ delete newAttributes[ key ];
106
+ continue;
67
107
  }
108
+
109
+ newAttributes[ key ] = serializeAttributeValue( value );
68
110
  }
69
111
  return newAttributes;
70
112
  }
@@ -76,12 +118,91 @@ function makeBlocksSerializable( blocks: Block[] ): Block[] {
76
118
  return {
77
119
  ...rest,
78
120
  name,
79
- attributes: makeBlockAttributesSerializable( attributes ),
121
+ attributes: makeBlockAttributesSerializable( name, attributes ),
80
122
  innerBlocks: makeBlocksSerializable( innerBlocks ),
81
123
  };
82
124
  } );
83
125
  }
84
126
 
127
+ /**
128
+ * Recursively walk an attribute value and convert any strings that correspond
129
+ * to rich-text schema nodes into RichTextData instances. This is the inverse
130
+ * of serializeAttributeValue and handles nested structures like table cells.
131
+ *
132
+ * @param schema The attribute type definition for this value.
133
+ * @param value The attribute value from CRDT (toJSON).
134
+ * @return The value with rich-text strings replaced by RichTextData.
135
+ */
136
+ function deserializeAttributeValue(
137
+ schema: BlockAttributeType | undefined,
138
+ value: unknown
139
+ ): unknown {
140
+ if ( schema?.type === 'rich-text' && typeof value === 'string' ) {
141
+ return getCachedRichTextData( value );
142
+ }
143
+
144
+ // e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
145
+ if ( Array.isArray( value ) ) {
146
+ return value.map( ( item ) =>
147
+ deserializeAttributeValue( schema, item )
148
+ );
149
+ }
150
+
151
+ // e.g. a single row inside core/table `body`: { cells: [ ... ] }
152
+ if ( value && typeof value === 'object' ) {
153
+ const result: Record< string, unknown > = {};
154
+
155
+ for ( const [ key, innerValue ] of Object.entries(
156
+ value as Record< string, unknown >
157
+ ) ) {
158
+ result[ key ] = deserializeAttributeValue(
159
+ schema?.query?.[ key ],
160
+ innerValue
161
+ );
162
+ }
163
+
164
+ return result;
165
+ }
166
+
167
+ return value;
168
+ }
169
+
170
+ /**
171
+ * Convert blocks from their CRDT-serialized form back to the runtime form
172
+ * expected by the block editor. Rich-text attributes are stored as Y.Text in
173
+ * the CRDT document, which serializes to plain strings via toJSON(). This
174
+ * function restores them to RichTextData instances so that block edit
175
+ * components that rely on RichTextData methods (e.g. `.text`) work correctly.
176
+ *
177
+ * @param blocks Blocks as extracted from the CRDT document via toJSON().
178
+ * @return Blocks with rich-text attributes restored to RichTextData.
179
+ */
180
+ export function deserializeBlockAttributes( blocks: Block[] ): Block[] {
181
+ return blocks.map( ( block: Block ) => {
182
+ const { name, innerBlocks, attributes, ...rest } = block;
183
+
184
+ const newAttributes = { ...attributes };
185
+
186
+ for ( const [ key, value ] of Object.entries( attributes ) ) {
187
+ const schema = getBlockAttributeType( name, key );
188
+
189
+ if ( schema ) {
190
+ newAttributes[ key ] = deserializeAttributeValue(
191
+ schema,
192
+ value
193
+ );
194
+ }
195
+ }
196
+
197
+ return {
198
+ ...rest,
199
+ name,
200
+ attributes: newAttributes,
201
+ innerBlocks: deserializeBlockAttributes( innerBlocks ?? [] ),
202
+ };
203
+ } );
204
+ }
205
+
85
206
  /**
86
207
  * @param {any} gblock
87
208
  * @param {Y.Map} yblock
@@ -202,12 +323,7 @@ export function mergeCrdtBlocks(
202
323
  makeBlocksSerializable( incomingBlocks )
203
324
  );
204
325
  }
205
- const allBlocks = serializableBlocksCache.get( incomingBlocks ) ?? [];
206
-
207
- // Ensure we skip blocks that we don't want to sync at the moment
208
- const blocksToSync = allBlocks.filter( ( block ) =>
209
- shouldBlockBeSynced( block )
210
- );
326
+ const blocksToSync = serializableBlocksCache.get( incomingBlocks ) ?? [];
211
327
 
212
328
  // This is a rudimentary diff implementation similar to the y-prosemirror diffing
213
329
  // approach.
@@ -283,43 +399,29 @@ export function mergeCrdtBlocks(
283
399
 
284
400
  Object.entries( value ).forEach(
285
401
  ( [ attributeName, attributeValue ] ) => {
286
- if (
287
- fastDeepEqual(
288
- currentAttributes?.get( attributeName ),
289
- attributeValue
290
- )
291
- ) {
292
- return;
293
- }
294
-
295
402
  const currentAttribute =
296
- currentAttributes.get( attributeName );
297
- const isRichText = isRichTextAttribute(
403
+ currentAttributes?.get( attributeName );
404
+
405
+ const isExpectedType = isExpectedAttributeType(
298
406
  block.name,
299
- attributeName
407
+ attributeName,
408
+ currentAttribute
300
409
  );
301
410
 
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(
411
+ const isAttributeChanged =
412
+ ! isExpectedType ||
413
+ ! fastDeepEqual(
311
414
  currentAttribute,
312
- attributeValue,
313
- cursorPosition
415
+ attributeValue
314
416
  );
315
- } else {
316
- currentAttributes.set(
417
+
418
+ if ( isAttributeChanged ) {
419
+ updateYBlockAttribute(
420
+ block.name,
317
421
  attributeName,
318
- createNewYAttributeValue(
319
- block.name,
320
- attributeName,
321
- attributeValue
322
- )
422
+ attributeValue,
423
+ currentAttributes,
424
+ cursorPosition
323
425
  );
324
426
  }
325
427
  }
@@ -397,69 +499,136 @@ export function mergeCrdtBlocks(
397
499
  }
398
500
 
399
501
  /**
400
- * Determine if a block should be synced.
502
+ * Update a single attribute on a Yjs block attributes map (currentAttributes).
401
503
  *
402
- * Ex: A gallery block should not be synced until the images have been
403
- * uploaded to WordPress, and their url is available. Before that,
404
- * it's not possible to access the blobs on a client as those are
405
- * local.
504
+ * For rich-text attributes that already exist as Y.Text instances, the update
505
+ * is applied as a delta merge so that concurrent edits are preserved. All
506
+ * other attributes are replaced wholesale via `createNewYAttributeValue`.
406
507
  *
407
- * @param block The block to check.
408
- * @return True if the block should be synced, false otherwise.
508
+ * @param blockName The block type name, e.g. 'core/paragraph'.
509
+ * @param attributeName The name of the attribute to update, e.g. 'content'.
510
+ * @param attributeValue The new value for the attribute.
511
+ * @param currentAttributes The Y.Map holding the block's current attributes.
512
+ * @param cursorPosition The local cursor position, used when merging rich-text deltas.
409
513
  */
410
- function shouldBlockBeSynced( block: Block ): boolean {
411
- // Verify that the gallery block is ready to be synced.
412
- // This means that, all images have had their blobs converted to full URLs.
413
- // Checking for only the blobs ensures that blocks that have just been inserted work as well.
414
- if ( 'core/gallery' === block.name ) {
415
- return ! block.innerBlocks.some(
416
- ( innerBlock ) =>
417
- innerBlock.attributes && innerBlock.attributes.blob
514
+ function updateYBlockAttribute(
515
+ blockName: string,
516
+ attributeName: string,
517
+ attributeValue: unknown,
518
+ currentAttributes: YBlockAttributes,
519
+ cursorPosition: number | null
520
+ ): void {
521
+ const isRichText = isRichTextAttribute( blockName, attributeName );
522
+ const currentAttribute = currentAttributes.get( attributeName );
523
+
524
+ if (
525
+ isRichText &&
526
+ 'string' === typeof attributeValue &&
527
+ currentAttributes.has( attributeName ) &&
528
+ currentAttribute instanceof Y.Text
529
+ ) {
530
+ // Rich text values are stored as persistent Y.Text instances.
531
+ // Update the value with a delta in place.
532
+ mergeRichTextUpdate( currentAttribute, attributeValue, cursorPosition );
533
+ } else {
534
+ currentAttributes.set(
535
+ attributeName,
536
+ createNewYAttributeValue( blockName, attributeName, attributeValue )
418
537
  );
419
538
  }
420
-
421
- // Allow all other blocks to be synced.
422
- return true;
423
539
  }
424
540
 
425
- // Cache rich-text attributes for all block types.
426
- let cachedRichTextAttributes: Map< string, Map< string, true > >;
541
+ // Cached block attribute types, populated once from getBlockTypes().
542
+ let cachedBlockAttributeTypes: Map< string, Map< string, BlockAttributeType > >;
427
543
 
428
544
  /**
429
- * Given a block name and attribute key, return true if the attribute is rich-text typed.
545
+ * Get the attribute type definition for a block attribute.
430
546
  *
431
547
  * @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.
548
+ * @param attributeName The name of the attribute, e.g. 'content'.
549
+ * @return The type definition of the attribute.
434
550
  */
435
- function isRichTextAttribute(
551
+ function getBlockAttributeType(
436
552
  blockName: string,
437
553
  attributeName: string
438
- ): boolean {
439
- if ( ! cachedRichTextAttributes ) {
554
+ ): BlockAttributeType | undefined {
555
+ if ( ! cachedBlockAttributeTypes ) {
440
556
  // Parse the attributes for all blocks once.
441
- cachedRichTextAttributes = new Map< string, Map< string, true > >();
557
+ cachedBlockAttributeTypes = new Map();
442
558
 
443
559
  for ( const blockType of getBlockTypes() as BlockType[] ) {
444
- const richTextAttributeMap = new Map< string, true >();
445
-
446
- for ( const [ name, definition ] of Object.entries(
447
- blockType.attributes ?? {}
448
- ) ) {
449
- if ( 'rich-text' === definition.type ) {
450
- richTextAttributeMap.set( name, true );
451
- }
452
- }
453
-
454
- cachedRichTextAttributes.set(
560
+ cachedBlockAttributeTypes.set(
455
561
  blockType.name,
456
- richTextAttributeMap
562
+ new Map< string, BlockAttributeType >(
563
+ Object.entries( blockType.attributes ?? {} ).map(
564
+ ( [ name, definition ] ) => {
565
+ const { role, type, query } = definition;
566
+ return [ name, { role, type, query } ];
567
+ }
568
+ )
569
+ )
457
570
  );
458
571
  }
459
572
  }
460
573
 
574
+ return cachedBlockAttributeTypes.get( blockName )?.get( attributeName );
575
+ }
576
+
577
+ /**
578
+ * Check if an attribute value is the expected type.
579
+ *
580
+ * @param blockName The name of the block, e.g. 'core/paragraph'.
581
+ * @param attributeName The name of the attribute, e.g. 'content'.
582
+ * @param attributeValue The current attribute value.
583
+ * @return True if the attribute type is expected, false otherwise.
584
+ */
585
+ function isExpectedAttributeType(
586
+ blockName: string,
587
+ attributeName: string,
588
+ attributeValue: unknown
589
+ ): boolean {
590
+ const expectedAttributeType = getBlockAttributeType(
591
+ blockName,
592
+ attributeName
593
+ )?.type;
594
+
595
+ if ( expectedAttributeType === 'rich-text' ) {
596
+ return attributeValue instanceof Y.Text;
597
+ }
598
+
599
+ if ( expectedAttributeType === 'string' ) {
600
+ return typeof attributeValue === 'string';
601
+ }
602
+
603
+ // No other types comparisons use special logic.
604
+ return true;
605
+ }
606
+
607
+ /**
608
+ * Given a block name and attribute key, return true if the attribute is local
609
+ * and should not be synced.
610
+ *
611
+ * @param blockName The name of the block, e.g. 'core/image'.
612
+ * @param attributeName The name of the attribute to check, e.g. 'blob'.
613
+ * @return True if the attribute is local, false otherwise.
614
+ */
615
+ function isLocalAttribute( blockName: string, attributeName: string ): boolean {
616
+ return 'local' === getBlockAttributeType( blockName, attributeName )?.role;
617
+ }
618
+
619
+ /**
620
+ * Given a block name and attribute key, return true if the attribute is rich-text typed.
621
+ *
622
+ * @param blockName The name of the block, e.g. 'core/paragraph'.
623
+ * @param attributeName The name of the attribute to check, e.g. 'content'.
624
+ * @return True if the attribute is rich-text typed, false otherwise.
625
+ */
626
+ function isRichTextAttribute(
627
+ blockName: string,
628
+ attributeName: string
629
+ ): boolean {
461
630
  return (
462
- cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false
631
+ 'rich-text' === getBlockAttributeType( blockName, attributeName )?.type
463
632
  );
464
633
  }
465
634
 
@@ -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 ) {
@@ -88,6 +94,41 @@ function convertYSelectionToBlockSelection(
88
94
  return null;
89
95
  }
90
96
 
97
+ /**
98
+ * Convert a YFullSelection to a WPSelection by resolving relative positions
99
+ * and verifying the blocks exist in the document.
100
+ * @param yFullSelection The YFullSelection to convert
101
+ * @param ydoc The Y.Doc to resolve positions against
102
+ * @return The converted WPSelection, or null if the conversion fails
103
+ */
104
+ function convertYFullSelectionToWPSelection(
105
+ yFullSelection: YFullSelection,
106
+ ydoc: Y.Doc
107
+ ): WPSelection | null {
108
+ const { start, end } = yFullSelection;
109
+ const startBlock = findBlockByClientIdInDoc( start.clientId, ydoc );
110
+ const endBlock = findBlockByClientIdInDoc( end.clientId, ydoc );
111
+
112
+ if ( ! startBlock || ! endBlock ) {
113
+ return null;
114
+ }
115
+
116
+ const startBlockSelection = convertYSelectionToBlockSelection(
117
+ start,
118
+ ydoc
119
+ );
120
+ const endBlockSelection = convertYSelectionToBlockSelection( end, ydoc );
121
+
122
+ if ( startBlockSelection === null || endBlockSelection === null ) {
123
+ return null;
124
+ }
125
+
126
+ return {
127
+ selectionStart: startBlockSelection,
128
+ selectionEnd: endBlockSelection,
129
+ };
130
+ }
131
+
91
132
  /**
92
133
  * Given a Y.Doc and a selection history, find the most recent selection
93
134
  * that exists in the document. Skip any selections that are not in the document.
@@ -99,34 +140,14 @@ function findSelectionFromHistory(
99
140
  ydoc: Y.Doc,
100
141
  selectionHistory: YFullSelection[]
101
142
  ): WPSelection | null {
102
- // Try each position until we find one that exists in the document
103
143
  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,
144
+ const result = convertYFullSelectionToWPSelection(
145
+ positionToTry,
115
146
  ydoc
116
147
  );
117
- const endBlockSelection = convertYSelectionToBlockSelection(
118
- end,
119
- ydoc
120
- );
121
-
122
- if ( startBlockSelection === null || endBlockSelection === null ) {
123
- continue;
148
+ if ( result !== null ) {
149
+ return result;
124
150
  }
125
-
126
- return {
127
- selectionStart: startBlockSelection,
128
- selectionEnd: endBlockSelection,
129
- };
130
151
  }
131
152
 
132
153
  return null;
@@ -205,3 +226,48 @@ export function restoreSelection(
205
226
  resetSelection( selectionEnd, selectionEnd, 0 );
206
227
  }
207
228
  }
229
+
230
+ /**
231
+ * If the latest selection has been shifted by remote edits, resolve and return
232
+ * it as a WPSelection. Returns null when the history is empty or neither
233
+ * endpoint has moved.
234
+ *
235
+ * @param ydoc The Y.Doc to resolve positions against
236
+ * @param selectionHistory The selection history to check
237
+ * @return The shifted WPSelection, or null if nothing moved.
238
+ */
239
+ export function getShiftedSelection(
240
+ ydoc: Y.Doc,
241
+ selectionHistory: YFullSelection[]
242
+ ): WPSelection | null {
243
+ if ( selectionHistory.length === 0 ) {
244
+ return null;
245
+ }
246
+
247
+ const { start, end } = selectionHistory[ 0 ];
248
+
249
+ // Block-level selections have no offset that can shift.
250
+ if (
251
+ start.type === YSelectionType.BlockSelection ||
252
+ end.type === YSelectionType.BlockSelection
253
+ ) {
254
+ return null;
255
+ }
256
+
257
+ const selectionStart = convertYSelectionToBlockSelection( start, ydoc );
258
+ const selectionEnd = convertYSelectionToBlockSelection( end, ydoc );
259
+
260
+ if ( ! selectionStart || ! selectionEnd ) {
261
+ return null;
262
+ }
263
+
264
+ // Only dispatch if at least one endpoint actually moved.
265
+ const startShifted = selectionStart.offset !== start.offset;
266
+ const endShifted = selectionEnd.offset !== end.offset;
267
+
268
+ if ( ! startShifted && ! endShifted ) {
269
+ return null;
270
+ }
271
+
272
+ return { selectionStart, selectionEnd };
273
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { RichTextData } from '@wordpress/rich-text';
5
+
6
+ const RICH_TEXT_CACHE_MAX_SIZE = 500;
7
+
8
+ /**
9
+ * Returns a function that converts HTML strings to RichTextData instances,
10
+ * using a FIFO cache bounded by `maxSize` to avoid re-parsing identical
11
+ * strings. Repeated calls with the same string return the cached instance
12
+ * without re-running the HTML parser and DOM traversal.
13
+ *
14
+ * @param maxSize Maximum number of entries to hold in the cache.
15
+ * @return A cached version of RichTextData.fromHTMLString.
16
+ */
17
+ export function createRichTextDataCache(
18
+ maxSize: number
19
+ ): ( value: string ) => RichTextData {
20
+ const cache = new Map< string, RichTextData >();
21
+
22
+ return function ( value: string ): RichTextData {
23
+ const cached = cache.get( value );
24
+
25
+ if ( cached ) {
26
+ return cached;
27
+ }
28
+
29
+ const result = RichTextData.fromHTMLString( value );
30
+
31
+ if ( cache.size >= maxSize ) {
32
+ // Evict the oldest entry (Map preserves insertion order).
33
+ cache.delete( cache.keys().next().value! );
34
+ }
35
+
36
+ cache.set( value, result );
37
+ return result;
38
+ };
39
+ }
40
+
41
+ export const getCachedRichTextData = createRichTextDataCache(
42
+ RICH_TEXT_CACHE_MAX_SIZE
43
+ );