@wordpress/core-data 7.41.2-next.v.202603161435.0 → 7.43.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 (161) hide show
  1. package/CHANGELOG.md +3 -1
  2. package/build/awareness/post-editor-awareness.cjs +12 -5
  3. package/build/awareness/post-editor-awareness.cjs.map +2 -2
  4. package/build/entities.cjs +30 -5
  5. package/build/entities.cjs.map +2 -2
  6. package/build/hooks/use-post-editor-awareness-state.cjs +1 -1
  7. package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
  8. package/build/private-actions.cjs +10 -0
  9. package/build/private-actions.cjs.map +2 -2
  10. package/build/private-selectors.cjs +20 -3
  11. package/build/private-selectors.cjs.map +2 -2
  12. package/build/queried-data/get-query-parts.cjs +8 -0
  13. package/build/queried-data/get-query-parts.cjs.map +2 -2
  14. package/build/queried-data/reducer.cjs +15 -9
  15. package/build/queried-data/reducer.cjs.map +2 -2
  16. package/build/queried-data/selectors.cjs +10 -2
  17. package/build/queried-data/selectors.cjs.map +2 -2
  18. package/build/reducer.cjs +22 -3
  19. package/build/reducer.cjs.map +2 -2
  20. package/build/resolvers.cjs +16 -8
  21. package/build/resolvers.cjs.map +2 -2
  22. package/build/selectors.cjs +16 -8
  23. package/build/selectors.cjs.map +2 -2
  24. package/build/sync.cjs +3 -0
  25. package/build/sync.cjs.map +2 -2
  26. package/build/types.cjs.map +2 -2
  27. package/build/utils/block-selection-history.cjs +1 -1
  28. package/build/utils/block-selection-history.cjs.map +2 -2
  29. package/build/utils/crdt-blocks.cjs +66 -5
  30. package/build/utils/crdt-blocks.cjs.map +2 -2
  31. package/build/utils/crdt-selection.cjs +4 -1
  32. package/build/utils/crdt-selection.cjs.map +2 -2
  33. package/build/utils/crdt-text.cjs +52 -0
  34. package/build/utils/crdt-text.cjs.map +7 -0
  35. package/build/utils/crdt-user-selections.cjs +1 -1
  36. package/build/utils/crdt-user-selections.cjs.map +2 -2
  37. package/build/utils/crdt-utils.cjs +54 -2
  38. package/build/utils/crdt-utils.cjs.map +2 -2
  39. package/build/utils/crdt.cjs +9 -23
  40. package/build/utils/crdt.cjs.map +2 -2
  41. package/build/utils/index.cjs +0 -3
  42. package/build/utils/index.cjs.map +2 -2
  43. package/build-module/awareness/post-editor-awareness.mjs +12 -5
  44. package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
  45. package/build-module/entities.mjs +30 -5
  46. package/build-module/entities.mjs.map +2 -2
  47. package/build-module/hooks/use-post-editor-awareness-state.mjs +1 -1
  48. package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
  49. package/build-module/private-actions.mjs +9 -0
  50. package/build-module/private-actions.mjs.map +2 -2
  51. package/build-module/private-selectors.mjs +19 -3
  52. package/build-module/private-selectors.mjs.map +2 -2
  53. package/build-module/queried-data/get-query-parts.mjs +8 -0
  54. package/build-module/queried-data/get-query-parts.mjs.map +2 -2
  55. package/build-module/queried-data/reducer.mjs +15 -9
  56. package/build-module/queried-data/reducer.mjs.map +2 -2
  57. package/build-module/queried-data/selectors.mjs +10 -2
  58. package/build-module/queried-data/selectors.mjs.map +2 -2
  59. package/build-module/reducer.mjs +20 -2
  60. package/build-module/reducer.mjs.map +2 -2
  61. package/build-module/resolvers.mjs +14 -7
  62. package/build-module/resolvers.mjs.map +2 -2
  63. package/build-module/selectors.mjs +16 -9
  64. package/build-module/selectors.mjs.map +2 -2
  65. package/build-module/sync.mjs +2 -0
  66. package/build-module/sync.mjs.map +2 -2
  67. package/build-module/types.mjs.map +2 -2
  68. package/build-module/utils/block-selection-history.mjs +5 -2
  69. package/build-module/utils/block-selection-history.mjs.map +2 -2
  70. package/build-module/utils/crdt-blocks.mjs +65 -5
  71. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  72. package/build-module/utils/crdt-selection.mjs +8 -2
  73. package/build-module/utils/crdt-selection.mjs.map +2 -2
  74. package/build-module/utils/crdt-text.mjs +26 -0
  75. package/build-module/utils/crdt-text.mjs.map +7 -0
  76. package/build-module/utils/crdt-user-selections.mjs +2 -2
  77. package/build-module/utils/crdt-user-selections.mjs.map +2 -2
  78. package/build-module/utils/crdt-utils.mjs +51 -1
  79. package/build-module/utils/crdt-utils.mjs.map +2 -2
  80. package/build-module/utils/crdt.mjs +10 -23
  81. package/build-module/utils/crdt.mjs.map +2 -2
  82. package/build-module/utils/index.mjs +8 -10
  83. package/build-module/utils/index.mjs.map +2 -2
  84. package/build-types/awareness/post-editor-awareness.d.ts +2 -2
  85. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  86. package/build-types/entities.d.ts.map +1 -1
  87. package/build-types/index.d.ts.map +1 -1
  88. package/build-types/private-actions.d.ts +10 -0
  89. package/build-types/private-actions.d.ts.map +1 -1
  90. package/build-types/private-selectors.d.ts +11 -4
  91. package/build-types/private-selectors.d.ts.map +1 -1
  92. package/build-types/queried-data/get-query-parts.d.ts +13 -14
  93. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  94. package/build-types/queried-data/reducer.d.ts +11 -5
  95. package/build-types/queried-data/reducer.d.ts.map +1 -1
  96. package/build-types/queried-data/selectors.d.ts.map +1 -1
  97. package/build-types/reducer.d.ts +11 -0
  98. package/build-types/reducer.d.ts.map +1 -1
  99. package/build-types/resolvers.d.ts +3 -0
  100. package/build-types/resolvers.d.ts.map +1 -1
  101. package/build-types/selectors.d.ts +1 -0
  102. package/build-types/selectors.d.ts.map +1 -1
  103. package/build-types/sync.d.ts +2 -2
  104. package/build-types/sync.d.ts.map +1 -1
  105. package/build-types/types.d.ts +4 -2
  106. package/build-types/types.d.ts.map +1 -1
  107. package/build-types/utils/block-selection-history.d.ts.map +1 -1
  108. package/build-types/utils/crdt-blocks.d.ts +11 -0
  109. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  110. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  111. package/build-types/utils/crdt-text.d.ts +16 -0
  112. package/build-types/utils/crdt-text.d.ts.map +1 -0
  113. package/build-types/utils/crdt-user-selections.d.ts +1 -2
  114. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  115. package/build-types/utils/crdt-utils.d.ts +20 -0
  116. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  117. package/build-types/utils/crdt.d.ts +6 -7
  118. package/build-types/utils/crdt.d.ts.map +1 -1
  119. package/build-types/utils/index.d.ts +0 -1
  120. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  121. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  122. package/package.json +18 -18
  123. package/src/awareness/post-editor-awareness.ts +13 -6
  124. package/src/awareness/test/post-editor-awareness.ts +15 -10
  125. package/src/entities.js +36 -5
  126. package/src/hooks/test/use-post-editor-awareness-state.ts +3 -3
  127. package/src/hooks/use-post-editor-awareness-state.ts +1 -1
  128. package/src/private-actions.js +18 -0
  129. package/src/private-selectors.ts +37 -4
  130. package/src/queried-data/get-query-parts.js +14 -7
  131. package/src/queried-data/reducer.js +28 -15
  132. package/src/queried-data/selectors.js +11 -3
  133. package/src/queried-data/test/get-query-parts.js +34 -0
  134. package/src/queried-data/test/reducer.js +78 -8
  135. package/src/queried-data/test/selectors.js +171 -0
  136. package/src/reducer.js +31 -0
  137. package/src/resolvers.js +23 -7
  138. package/src/selectors.ts +20 -19
  139. package/src/sync.ts +2 -0
  140. package/src/test/entities.js +185 -1
  141. package/src/types.ts +8 -2
  142. package/src/utils/block-selection-history.ts +5 -2
  143. package/src/utils/crdt-blocks.ts +115 -5
  144. package/src/utils/crdt-selection.ts +8 -2
  145. package/src/utils/crdt-text.ts +43 -0
  146. package/src/utils/crdt-user-selections.ts +13 -13
  147. package/src/utils/crdt-utils.ts +99 -0
  148. package/src/utils/crdt.ts +18 -30
  149. package/src/utils/index.js +0 -1
  150. package/src/utils/test/crdt-blocks.ts +199 -0
  151. package/src/utils/test/crdt-user-selections.ts +5 -0
  152. package/src/utils/test/crdt-utils.ts +387 -0
  153. package/src/utils/test/crdt.ts +229 -54
  154. package/build/utils/is-raw-attribute.cjs +0 -29
  155. package/build/utils/is-raw-attribute.cjs.map +0 -7
  156. package/build-module/utils/is-raw-attribute.mjs +0 -8
  157. package/build-module/utils/is-raw-attribute.mjs.map +0 -7
  158. package/build-types/utils/is-raw-attribute.d.ts +0 -10
  159. package/build-types/utils/is-raw-attribute.d.ts.map +0 -1
  160. package/src/utils/is-raw-attribute.js +0 -11
  161. package/src/utils/test/is-raw-attribute.js +0 -22
@@ -8,6 +8,10 @@ jest.mock( '../sync', () => ( {
8
8
  ...jest.requireActual( '../sync' ),
9
9
  getSyncManager: jest.fn(),
10
10
  } ) );
11
+ jest.mock( '../utils/crdt', () => ( {
12
+ ...jest.requireActual( '../utils/crdt' ),
13
+ applyPostChangesToCRDTDoc: jest.fn(),
14
+ } ) );
11
15
 
12
16
  /**
13
17
  * Internal dependencies
@@ -19,7 +23,10 @@ import {
19
23
  additionalEntityConfigLoaders,
20
24
  } from '../entities';
21
25
  import { getSyncManager } from '../sync';
22
- import { POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE } from '../utils/crdt';
26
+ import {
27
+ applyPostChangesToCRDTDoc,
28
+ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
29
+ } from '../utils/crdt';
23
30
 
24
31
  describe( 'getMethodName', () => {
25
32
  it( 'should return the right method name for an entity with the root kind', () => {
@@ -127,6 +134,183 @@ describe( 'prePersistPostType', () => {
127
134
  } );
128
135
  } );
129
136
 
137
+ describe( 'loadPostTypeEntities', () => {
138
+ let originalCollaborationEnabled;
139
+
140
+ beforeEach( () => {
141
+ apiFetch.mockReset();
142
+ applyPostChangesToCRDTDoc.mockReset();
143
+ originalCollaborationEnabled = window._wpCollaborationEnabled;
144
+ } );
145
+
146
+ afterEach( () => {
147
+ window._wpCollaborationEnabled = originalCollaborationEnabled;
148
+ } );
149
+
150
+ it( 'should include custom taxonomy rest_bases in synced properties when collaboration is enabled', async () => {
151
+ window._wpCollaborationEnabled = true;
152
+
153
+ const mockPostTypes = {
154
+ book: {
155
+ name: 'Books',
156
+ rest_base: 'books',
157
+ rest_namespace: 'wp/v2',
158
+ taxonomies: [ 'genre', 'audience' ],
159
+ },
160
+ };
161
+ const mockTaxonomies = {
162
+ genre: {
163
+ name: 'Genres',
164
+ rest_base: 'genres',
165
+ rest_namespace: 'wp/v2',
166
+ },
167
+ audience: {
168
+ name: 'Audiences',
169
+ rest_base: 'audiences',
170
+ rest_namespace: 'wp/v2',
171
+ },
172
+ };
173
+
174
+ apiFetch
175
+ .mockResolvedValueOnce( mockPostTypes )
176
+ .mockResolvedValueOnce( mockTaxonomies );
177
+
178
+ const postTypeLoader = additionalEntityConfigLoaders.find(
179
+ ( loader ) => loader.kind === 'postType'
180
+ );
181
+ const entities = await postTypeLoader.loadEntities();
182
+ const bookEntity = entities.find( ( e ) => e.name === 'book' );
183
+
184
+ bookEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
185
+
186
+ expect( applyPostChangesToCRDTDoc ).toHaveBeenCalledWith(
187
+ {},
188
+ {},
189
+ expect.any( Set )
190
+ );
191
+
192
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
193
+ expect( syncedProperties ).toContain( 'genres' );
194
+ expect( syncedProperties ).toContain( 'audiences' );
195
+ } );
196
+
197
+ it( 'should not fetch taxonomies when collaboration is disabled', async () => {
198
+ window._wpCollaborationEnabled = false;
199
+
200
+ const mockPostTypes = {
201
+ post: {
202
+ name: 'Posts',
203
+ rest_base: 'posts',
204
+ rest_namespace: 'wp/v2',
205
+ taxonomies: [ 'category', 'post_tag' ],
206
+ },
207
+ };
208
+
209
+ apiFetch.mockResolvedValueOnce( mockPostTypes );
210
+
211
+ const postTypeLoader = additionalEntityConfigLoaders.find(
212
+ ( loader ) => loader.kind === 'postType'
213
+ );
214
+ const entities = await postTypeLoader.loadEntities();
215
+ const postEntity = entities.find( ( e ) => e.name === 'post' );
216
+
217
+ postEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
218
+
219
+ // Only one apiFetch call (post types), no taxonomy fetch.
220
+ expect( apiFetch ).toHaveBeenCalledTimes( 1 );
221
+
222
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
223
+ expect( syncedProperties ).not.toContain( 'categories' );
224
+ expect( syncedProperties ).not.toContain( 'tags' );
225
+ } );
226
+
227
+ it( 'should skip taxonomy rest_base when taxonomy is not found in fetched taxonomies', async () => {
228
+ window._wpCollaborationEnabled = true;
229
+
230
+ const mockPostTypes = {
231
+ book: {
232
+ name: 'Books',
233
+ rest_base: 'books',
234
+ rest_namespace: 'wp/v2',
235
+ taxonomies: [ 'genre', 'missing_taxonomy' ],
236
+ },
237
+ };
238
+ const mockTaxonomies = {
239
+ genre: {
240
+ name: 'Genres',
241
+ rest_base: 'genres',
242
+ rest_namespace: 'wp/v2',
243
+ },
244
+ // 'missing_taxonomy' is intentionally absent.
245
+ };
246
+
247
+ apiFetch
248
+ .mockResolvedValueOnce( mockPostTypes )
249
+ .mockResolvedValueOnce( mockTaxonomies );
250
+
251
+ const postTypeLoader = additionalEntityConfigLoaders.find(
252
+ ( loader ) => loader.kind === 'postType'
253
+ );
254
+ const entities = await postTypeLoader.loadEntities();
255
+ const bookEntity = entities.find( ( e ) => e.name === 'book' );
256
+
257
+ bookEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
258
+
259
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
260
+ expect( syncedProperties ).toContain( 'genres' );
261
+ // missing_taxonomy has no rest_base entry, so nothing should be added for it.
262
+ expect( syncedProperties.size ).toBe( 16 ); // 15 base + 1 taxonomy (genres)
263
+ } );
264
+
265
+ it( 'should include base synced properties regardless of taxonomies', async () => {
266
+ window._wpCollaborationEnabled = true;
267
+
268
+ const mockPostTypes = {
269
+ page: {
270
+ name: 'Pages',
271
+ rest_base: 'pages',
272
+ rest_namespace: 'wp/v2',
273
+ taxonomies: [],
274
+ },
275
+ };
276
+
277
+ apiFetch
278
+ .mockResolvedValueOnce( mockPostTypes )
279
+ .mockResolvedValueOnce( {} );
280
+
281
+ const postTypeLoader = additionalEntityConfigLoaders.find(
282
+ ( loader ) => loader.kind === 'postType'
283
+ );
284
+ const entities = await postTypeLoader.loadEntities();
285
+ const pageEntity = entities.find( ( e ) => e.name === 'page' );
286
+
287
+ pageEntity.syncConfig.applyChangesToCRDTDoc( {}, {} );
288
+
289
+ const syncedProperties = applyPostChangesToCRDTDoc.mock.calls[ 0 ][ 2 ];
290
+ const expectedBase = [
291
+ 'author',
292
+ 'blocks',
293
+ 'content',
294
+ 'comment_status',
295
+ 'date',
296
+ 'excerpt',
297
+ 'featured_media',
298
+ 'format',
299
+ 'meta',
300
+ 'ping_status',
301
+ 'slug',
302
+ 'status',
303
+ 'sticky',
304
+ 'template',
305
+ 'title',
306
+ ];
307
+ for ( const prop of expectedBase ) {
308
+ expect( syncedProperties ).toContain( prop );
309
+ }
310
+ expect( syncedProperties.size ).toBe( 15 );
311
+ } );
312
+ } );
313
+
130
314
  describe( 'loadTaxonomyEntities', () => {
131
315
  beforeEach( () => {
132
316
  apiFetch.mockReset();
package/src/types.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * WordPress dependencies
3
3
  */
4
- import type { Y } from '@wordpress/sync';
4
+ import type { ConnectionStatusDisconnected, Y } from '@wordpress/sync';
5
5
 
6
6
  /**
7
7
  * Internal dependencies
8
8
  */
9
9
  import type { SelectionType } from './utils/crdt-user-selections';
10
10
 
11
+ export type { ConnectionStatus } from '@wordpress/sync';
12
+
13
+ export type ConnectionError = NonNullable<
14
+ ConnectionStatusDisconnected[ 'error' ]
15
+ >;
16
+
11
17
  export interface AnyFunction {
12
18
  ( ...args: any[] ): any;
13
19
  }
@@ -127,6 +133,6 @@ export type SelectionState =
127
133
  | SelectionWholeBlock;
128
134
 
129
135
  export interface ResolvedSelection {
130
- textIndex: number | null;
136
+ richTextOffset: number | null;
131
137
  localClientId: string | null;
132
138
  }
@@ -9,7 +9,10 @@ import { Y } from '@wordpress/sync';
9
9
  /**
10
10
  * Internal dependencies
11
11
  */
12
- import { findBlockByClientIdInDoc } from './crdt-utils';
12
+ import {
13
+ findBlockByClientIdInDoc,
14
+ richTextOffsetToHtmlIndex,
15
+ } from './crdt-utils';
13
16
  import type { WPBlockSelection, WPSelection } from '../types';
14
17
 
15
18
  // Default size for selection history (not including current selection)
@@ -163,7 +166,7 @@ function convertWPBlockSelectionToSelection(
163
166
  const offset = selection.offset ?? 0;
164
167
  const relativePosition = Y.createRelativePositionFromTypeIndex(
165
168
  changedYText,
166
- offset
169
+ richTextOffsetToHtmlIndex( changedYText.toString(), offset )
167
170
  );
168
171
 
169
172
  return {
@@ -16,6 +16,7 @@ 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 {
@@ -25,6 +26,7 @@ interface BlockAttributes {
25
26
  interface BlockAttributeType {
26
27
  role?: string;
27
28
  type?: string;
29
+ query?: Record< string, BlockAttributeType >;
28
30
  }
29
31
 
30
32
  interface BlockType {
@@ -62,6 +64,37 @@ export type YBlockAttributes = Y.Map< Y.Text | unknown >;
62
64
 
63
65
  const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
64
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
+
65
98
  function makeBlockAttributesSerializable(
66
99
  blockName: string,
67
100
  attributes: BlockAttributes
@@ -73,9 +106,7 @@ function makeBlockAttributesSerializable(
73
106
  continue;
74
107
  }
75
108
 
76
- if ( value instanceof RichTextData ) {
77
- newAttributes[ key ] = value.valueOf();
78
- }
109
+ newAttributes[ key ] = serializeAttributeValue( value );
79
110
  }
80
111
  return newAttributes;
81
112
  }
@@ -93,6 +124,85 @@ function makeBlocksSerializable( blocks: Block[] ): Block[] {
93
124
  } );
94
125
  }
95
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
+
96
206
  /**
97
207
  * @param {any} gblock
98
208
  * @param {Y.Map} yblock
@@ -452,8 +562,8 @@ function getBlockAttributeType(
452
562
  new Map< string, BlockAttributeType >(
453
563
  Object.entries( blockType.attributes ?? {} ).map(
454
564
  ( [ name, definition ] ) => {
455
- const { role, type } = definition;
456
- return [ name, { role, type } ];
565
+ const { role, type, query } = definition;
566
+ return [ name, { role, type, query } ];
457
567
  }
458
568
  )
459
569
  )
@@ -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 ) {
@@ -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
+ );
@@ -12,18 +12,18 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
12
12
  import { CRDT_RECORD_MAP_KEY } from '../sync';
13
13
  import type { YPostRecord } from './crdt';
14
14
  import type { YBlock, YBlocks } from './crdt-blocks';
15
- import { getRootMap } from './crdt-utils';
16
- import type { SelectionDirection } from '../types';
17
- import {
18
- type AbsoluteBlockIndexPath,
19
- type WPBlockSelection,
20
- type SelectionState,
21
- type SelectionNone,
22
- type SelectionCursor,
23
- type SelectionInOneBlock,
24
- type SelectionInMultipleBlocks,
25
- type SelectionWholeBlock,
26
- type CursorPosition,
15
+ import { getRootMap, richTextOffsetToHtmlIndex } from './crdt-utils';
16
+ import type {
17
+ AbsoluteBlockIndexPath,
18
+ WPBlockSelection,
19
+ SelectionState,
20
+ SelectionNone,
21
+ SelectionCursor,
22
+ SelectionInOneBlock,
23
+ SelectionInMultipleBlocks,
24
+ SelectionWholeBlock,
25
+ SelectionDirection,
26
+ CursorPosition,
27
27
  } from '../types';
28
28
 
29
29
  /**
@@ -178,7 +178,7 @@ function getCursorPosition(
178
178
 
179
179
  const relativePosition = Y.createRelativePositionFromTypeIndex(
180
180
  currentYText,
181
- selection.offset
181
+ richTextOffsetToHtmlIndex( currentYText.toString(), selection.offset )
182
182
  );
183
183
 
184
184
  return {
@@ -2,6 +2,7 @@
2
2
  * WordPress dependencies
3
3
  */
4
4
  import { Y } from '@wordpress/sync';
5
+ import { create, insert, toHTMLString } from '@wordpress/rich-text';
5
6
 
6
7
  /**
7
8
  * Internal dependencies
@@ -104,6 +105,104 @@ export function findBlockByClientIdInDoc(
104
105
  return findBlockByClientIdInBlocks( blockId, blocks );
105
106
  }
106
107
 
108
+ // Marker for insertion.
109
+ const MARKER_START = 0xe000;
110
+
111
+ /**
112
+ * Pick a marker character that does not appear in `text`. Returns the marker
113
+ * or `null` if all candidates are present (extremely unlikely in practice).
114
+ *
115
+ * @param text The string to check for existing marker characters.
116
+ */
117
+ function pickMarker( text: string ): string | null {
118
+ const tryCount = 0x10;
119
+
120
+ // Scan the unicode private use area for the first code point not present
121
+ // in the text.
122
+ for ( let code = MARKER_START; code < MARKER_START + tryCount; code++ ) {
123
+ const candidate = String.fromCharCode( code );
124
+
125
+ if ( ! text.includes( candidate ) ) {
126
+ return candidate;
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Convert an HTML character index (counting tag characters) to a rich-text
135
+ * offset (counting only text characters). Used on read paths where Y.Text
136
+ * resolves to an HTML index but the block editor expects a text offset.
137
+ *
138
+ * @param html The full HTML string from Y.Text.
139
+ * @param htmlIndex The HTML character index.
140
+ * @return The corresponding rich-text offset.
141
+ */
142
+ export function htmlIndexToRichTextOffset(
143
+ html: string,
144
+ htmlIndex: number
145
+ ): number {
146
+ if ( ! html.includes( '<' ) && ! html.includes( '&' ) ) {
147
+ return htmlIndex;
148
+ }
149
+
150
+ const marker = pickMarker( html );
151
+ if ( ! marker ) {
152
+ return htmlIndex;
153
+ }
154
+
155
+ // Insert marker and let create() do the parsing.
156
+ const withMarker =
157
+ html.slice( 0, htmlIndex ) + marker + html.slice( htmlIndex );
158
+ const value = create( { html: withMarker } );
159
+ const markerPos = value.text.indexOf( marker );
160
+
161
+ return markerPos === -1 ? htmlIndex : markerPos;
162
+ }
163
+
164
+ /**
165
+ * Convert a rich-text offset (counting only text characters) to an HTML
166
+ * character index (counting tag characters). Used on write paths where the
167
+ * block editor provides a text offset but Y.Text expects an HTML index.
168
+ *
169
+ * @param html The full HTML string from Y.Text.
170
+ * @param richTextOffset The rich-text text offset.
171
+ * @return The corresponding HTML character index.
172
+ */
173
+ export function richTextOffsetToHtmlIndex(
174
+ html: string,
175
+ richTextOffset: number
176
+ ): number {
177
+ if ( ! html.includes( '<' ) && ! html.includes( '&' ) ) {
178
+ return richTextOffset;
179
+ }
180
+
181
+ const marker = pickMarker( html );
182
+ if ( ! marker ) {
183
+ return richTextOffset;
184
+ }
185
+
186
+ const value = create( { html } );
187
+ const markerValue = create( { text: marker } );
188
+ // The marker must inherit the formatting at the insertion point so that
189
+ // toHTMLString does not split surrounding tags (e.g. <strong>) around it.
190
+ if ( value.formats[ richTextOffset ] ) {
191
+ markerValue.formats[ 0 ] = value.formats[ richTextOffset ];
192
+ }
193
+
194
+ const withMarker = insert(
195
+ value,
196
+ markerValue,
197
+ richTextOffset,
198
+ richTextOffset
199
+ );
200
+
201
+ const htmlWithMarker = toHTMLString( { value: withMarker } );
202
+ const markerIndex = htmlWithMarker.indexOf( marker );
203
+ return markerIndex === -1 ? richTextOffset : markerIndex;
204
+ }
205
+
107
206
  function findBlockByClientIdInBlocks(
108
207
  blockId: string,
109
208
  blocks: YBlocks