@wordpress/core-data 7.40.2-next.v.202602271551.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 (182) 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 +36 -16
  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 +24 -17
  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 +12 -3
  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 +85 -28
  39. package/build/utils/crdt-blocks.cjs.map +2 -2
  40. package/build/utils/crdt-selection.cjs +4 -1
  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 +26 -28
  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 +37 -16
  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 +24 -17
  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 +8 -2
  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 +84 -28
  85. package/build-module/utils/crdt-blocks.mjs.map +2 -2
  86. package/build-module/utils/crdt-selection.mjs +8 -2
  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 +26 -28
  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 +10 -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 +11 -0
  131. package/build-types/utils/crdt-blocks.d.ts.map +1 -1
  132. package/build-types/utils/crdt-selection.d.ts.map +1 -1
  133. package/build-types/utils/crdt-text.d.ts +16 -0
  134. package/build-types/utils/crdt-text.d.ts.map +1 -0
  135. package/build-types/utils/crdt-user-selections.d.ts +17 -4
  136. package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
  137. package/build-types/utils/crdt-utils.d.ts +20 -0
  138. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  139. package/build-types/utils/crdt.d.ts +13 -7
  140. package/build-types/utils/crdt.d.ts.map +1 -1
  141. package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
  142. package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
  143. package/build-types/utils/save-crdt-doc.d.ts +8 -0
  144. package/build-types/utils/save-crdt-doc.d.ts.map +1 -0
  145. package/build-types/utils/test/crdt-utils.d.ts +2 -0
  146. package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
  147. package/package.json +18 -18
  148. package/src/actions.js +26 -41
  149. package/src/awareness/post-editor-awareness.ts +106 -7
  150. package/src/awareness/test/post-editor-awareness.ts +50 -10
  151. package/src/awareness/types.ts +7 -1
  152. package/src/entities.js +43 -17
  153. package/src/entity-provider.js +24 -11
  154. package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
  155. package/src/hooks/use-entity-prop.js +43 -3
  156. package/src/hooks/use-post-editor-awareness-state.ts +229 -7
  157. package/src/index.js +0 -6
  158. package/src/private-actions.js +43 -1
  159. package/src/{private-apis.js → private-apis.ts} +21 -3
  160. package/src/private-selectors.ts +43 -0
  161. package/src/queried-data/actions.js +1 -1
  162. package/src/queried-data/reducer.js +26 -14
  163. package/src/reducer.js +36 -1
  164. package/src/resolvers.js +33 -23
  165. package/src/selectors.ts +1 -32
  166. package/src/sync.ts +6 -0
  167. package/src/test/entities.js +204 -16
  168. package/src/test/private-actions.js +1 -1
  169. package/src/test/resolvers.js +144 -57
  170. package/src/test/store.js +116 -0
  171. package/src/types.ts +20 -2
  172. package/src/utils/block-selection-history.ts +5 -2
  173. package/src/utils/crdt-blocks.ts +159 -56
  174. package/src/utils/crdt-selection.ts +8 -2
  175. package/src/utils/crdt-text.ts +43 -0
  176. package/src/utils/crdt-user-selections.ts +29 -8
  177. package/src/utils/crdt-utils.ts +99 -0
  178. package/src/utils/crdt.ts +50 -40
  179. package/src/utils/test/crdt-blocks.ts +588 -24
  180. package/src/utils/test/crdt-user-selections.ts +5 -0
  181. package/src/utils/test/crdt-utils.ts +387 -0
  182. package/src/utils/test/crdt.ts +291 -54
package/src/test/store.js CHANGED
@@ -113,6 +113,122 @@ describe( 'getEntityRecord', () => {
113
113
  } );
114
114
  } );
115
115
 
116
+ describe( 'getEntityRecords', () => {
117
+ const POSTS = [
118
+ createTestPost( 1 ),
119
+ createTestPost( 2 ),
120
+ createTestPost( 3 ),
121
+ ];
122
+
123
+ let registry;
124
+
125
+ beforeEach( () => {
126
+ registry = createTestRegistry();
127
+ triggerFetch.mockReset();
128
+ } );
129
+
130
+ it( 'preserves collection when getEntityRecord resolves after getEntityRecords', async () => {
131
+ let resolveSlowFetch;
132
+ const slowFetchPromise = new Promise( ( resolve ) => {
133
+ resolveSlowFetch = resolve;
134
+ } );
135
+
136
+ triggerFetch.mockImplementation( ( { path } ) => {
137
+ // Single post fetch (e.g. /wp/v2/posts/1): return slow promise.
138
+ if ( /\/wp\/v2\/posts\/\d+/.test( path ) ) {
139
+ return slowFetchPromise;
140
+ }
141
+ // Collection fetch: return immediately.
142
+ return Promise.resolve( {
143
+ json: () => Promise.resolve( POSTS ),
144
+ headers: {
145
+ get: ( header ) => {
146
+ if ( header === 'X-WP-Total' ) {
147
+ return String( POSTS.length );
148
+ }
149
+ if ( header === 'X-WP-TotalPages' ) {
150
+ return '1';
151
+ }
152
+ return null;
153
+ },
154
+ },
155
+ } );
156
+ } );
157
+
158
+ const resolveSelectStore = registry.resolveSelect( coreDataStore );
159
+
160
+ // Start getEntityRecord first (slow), then getEntityRecords (fast).
161
+ const singlePromise = resolveSelectStore.getEntityRecord(
162
+ 'postType',
163
+ 'post',
164
+ 1,
165
+ { context: 'edit' }
166
+ );
167
+ await resolveSelectStore.getEntityRecords( 'postType', 'post', {
168
+ context: 'edit',
169
+ } );
170
+
171
+ // Now resolve the slow single-record fetch.
172
+ resolveSlowFetch( {
173
+ json: () => Promise.resolve( POSTS[ 0 ] ),
174
+ headers: { get: () => null },
175
+ } );
176
+ await singlePromise;
177
+
178
+ // Wait for all pending thunks to settle.
179
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
180
+
181
+ const allPosts = registry
182
+ .select( coreDataStore )
183
+ .getEntityRecords( 'postType', 'post', { context: 'edit' } );
184
+ expect( allPosts.map( ( p ) => p.id ) ).toEqual( [ 1, 2, 3 ] );
185
+ } );
186
+
187
+ it( 'preserves collection when getEntityRecord is called after getEntityRecords', async () => {
188
+ triggerFetch.mockImplementation( ( { path } ) => {
189
+ // Collection fetch.
190
+ if ( ! /\/wp\/v2\/posts\/\d+/.test( path ) ) {
191
+ return Promise.resolve( {
192
+ json: () => Promise.resolve( POSTS ),
193
+ headers: {
194
+ get: ( header ) => {
195
+ if ( header === 'X-WP-Total' ) {
196
+ return String( POSTS.length );
197
+ }
198
+ if ( header === 'X-WP-TotalPages' ) {
199
+ return '1';
200
+ }
201
+ return null;
202
+ },
203
+ },
204
+ } );
205
+ }
206
+ // Single post fetch.
207
+ return Promise.resolve( {
208
+ json: () => Promise.resolve( POSTS[ 0 ] ),
209
+ headers: { get: () => null },
210
+ } );
211
+ } );
212
+
213
+ const resolveSelectStore = registry.resolveSelect( coreDataStore );
214
+
215
+ // First resolve the collection.
216
+ await resolveSelectStore.getEntityRecords( 'postType', 'post', {
217
+ context: 'edit',
218
+ } );
219
+
220
+ // Then resolve a single record.
221
+ await resolveSelectStore.getEntityRecord( 'postType', 'post', 1, {
222
+ context: 'edit',
223
+ } );
224
+
225
+ const allPosts = registry
226
+ .select( coreDataStore )
227
+ .getEntityRecords( 'postType', 'post', { context: 'edit' } );
228
+ expect( allPosts.map( ( p ) => p.id ) ).toEqual( [ 1, 2, 3 ] );
229
+ } );
230
+ } );
231
+
116
232
  describe( 'clearEntityRecordEdits', () => {
117
233
  let registry;
118
234
 
package/src/types.ts CHANGED
@@ -1,12 +1,21 @@
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
- import type { SelectionType } from './utils/crdt-user-selections';
9
+ import type {
10
+ SelectionType,
11
+ SelectionDirection,
12
+ } from './utils/crdt-user-selections';
13
+
14
+ export type { ConnectionStatus } from '@wordpress/sync';
15
+
16
+ export type ConnectionError = NonNullable<
17
+ ConnectionStatusDisconnected[ 'error' ]
18
+ >;
10
19
 
11
20
  export interface AnyFunction {
12
21
  ( ...args: any[] ): any;
@@ -86,6 +95,8 @@ export type SelectionInOneBlock = {
86
95
  type: SelectionType.SelectionInOneBlock;
87
96
  cursorStartPosition: CursorPosition;
88
97
  cursorEndPosition: CursorPosition;
98
+ // The direction of the selection, indicating where the caret sits.
99
+ selectionDirection?: SelectionDirection;
89
100
  };
90
101
 
91
102
  export type SelectionInMultipleBlocks = {
@@ -95,6 +106,8 @@ export type SelectionInMultipleBlocks = {
95
106
  type: SelectionType.SelectionInMultipleBlocks;
96
107
  cursorStartPosition: CursorPosition;
97
108
  cursorEndPosition: CursorPosition;
109
+ // The direction of the selection, indicating where the caret sits.
110
+ selectionDirection?: SelectionDirection;
98
111
  };
99
112
 
100
113
  export type SelectionWholeBlock = {
@@ -111,3 +124,8 @@ export type SelectionState =
111
124
  | SelectionInOneBlock
112
125
  | SelectionInMultipleBlocks
113
126
  | SelectionWholeBlock;
127
+
128
+ export interface ResolvedSelection {
129
+ richTextOffset: number | null;
130
+ localClientId: string | null;
131
+ }
@@ -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,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.
@@ -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.
@@ -382,32 +498,6 @@ export function mergeCrdtBlocks(
382
498
  }
383
499
  }
384
500
 
385
- /**
386
- * Determine if a block should be synced.
387
- *
388
- * Ex: A gallery block should not be synced until the images have been
389
- * uploaded to WordPress, and their url is available. Before that,
390
- * it's not possible to access the blobs on a client as those are
391
- * local.
392
- *
393
- * @param block The block to check.
394
- * @return True if the block should be synced, false otherwise.
395
- */
396
- function shouldBlockBeSynced( block: Block ): boolean {
397
- // Verify that the gallery block is ready to be synced.
398
- // This means that, all images have had their blobs converted to full URLs.
399
- // Checking for only the blobs ensures that blocks that have just been inserted work as well.
400
- if ( 'core/gallery' === block.name ) {
401
- return ! block.innerBlocks.some(
402
- ( innerBlock ) =>
403
- innerBlock.attributes && innerBlock.attributes.blob
404
- );
405
- }
406
-
407
- // Allow all other blocks to be synced.
408
- return true;
409
- }
410
-
411
501
  /**
412
502
  * Update a single attribute on a Yjs block attributes map (currentAttributes).
413
503
  *
@@ -448,38 +538,35 @@ function updateYBlockAttribute(
448
538
  }
449
539
  }
450
540
 
451
- // Cached types for block attributes.
452
- let cachedBlockAttributeTypes: Map< string, Map< string, string > >;
541
+ // Cached block attribute types, populated once from getBlockTypes().
542
+ let cachedBlockAttributeTypes: Map< string, Map< string, BlockAttributeType > >;
453
543
 
454
544
  /**
455
- * Get the defined attribute type for a block attribute.
545
+ * Get the attribute type definition for a block attribute.
456
546
  *
457
547
  * @param blockName The name of the block, e.g. 'core/paragraph'.
458
548
  * @param attributeName The name of the attribute, e.g. 'content'.
459
- * @return The type of the attribute, e.g. 'rich-text' or 'string'.
549
+ * @return The type definition of the attribute.
460
550
  */
461
551
  function getBlockAttributeType(
462
552
  blockName: string,
463
553
  attributeName: string
464
- ): string | undefined {
554
+ ): BlockAttributeType | undefined {
465
555
  if ( ! cachedBlockAttributeTypes ) {
466
556
  // Parse the attributes for all blocks once.
467
- cachedBlockAttributeTypes = new Map< string, Map< string, string > >();
557
+ cachedBlockAttributeTypes = new Map();
468
558
 
469
559
  for ( const blockType of getBlockTypes() as BlockType[] ) {
470
- const blockAttributeTypeMap = new Map< string, string >();
471
-
472
- for ( const [ name, definition ] of Object.entries(
473
- blockType.attributes ?? {}
474
- ) ) {
475
- if ( definition.type ) {
476
- blockAttributeTypeMap.set( name, definition.type );
477
- }
478
- }
479
-
480
560
  cachedBlockAttributeTypes.set(
481
561
  blockType.name,
482
- blockAttributeTypeMap
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
+ )
483
570
  );
484
571
  }
485
572
  }
@@ -503,11 +590,13 @@ function isExpectedAttributeType(
503
590
  const expectedAttributeType = getBlockAttributeType(
504
591
  blockName,
505
592
  attributeName
506
- );
593
+ )?.type;
507
594
 
508
595
  if ( expectedAttributeType === 'rich-text' ) {
509
596
  return attributeValue instanceof Y.Text;
510
- } else if ( expectedAttributeType === 'string' ) {
597
+ }
598
+
599
+ if ( expectedAttributeType === 'string' ) {
511
600
  return typeof attributeValue === 'string';
512
601
  }
513
602
 
@@ -515,6 +604,18 @@ function isExpectedAttributeType(
515
604
  return true;
516
605
  }
517
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
+
518
619
  /**
519
620
  * Given a block name and attribute key, return true if the attribute is rich-text typed.
520
621
  *
@@ -526,7 +627,9 @@ function isRichTextAttribute(
526
627
  blockName: string,
527
628
  attributeName: string
528
629
  ): boolean {
529
- return 'rich-text' === getBlockAttributeType( blockName, attributeName );
630
+ return (
631
+ 'rich-text' === getBlockAttributeType( blockName, attributeName )?.type
632
+ );
530
633
  }
531
634
 
532
635
  let localDoc: Y.Doc;
@@ -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,7 +12,7 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
12
12
  import { CRDT_RECORD_MAP_KEY } from '../sync';
13
13
  import type { YPostRecord } from './crdt';
14
14
  import type { YBlock, YBlocks } from './crdt-blocks';
15
- import { getRootMap } from './crdt-utils';
15
+ import { getRootMap, richTextOffsetToHtmlIndex } from './crdt-utils';
16
16
  import type {
17
17
  AbsoluteBlockIndexPath,
18
18
  WPBlockSelection,
@@ -36,6 +36,16 @@ export enum SelectionType {
36
36
  WholeBlock = 'whole-block',
37
37
  }
38
38
 
39
+ /**
40
+ * The direction of a text selection, indicating where the caret sits.
41
+ */
42
+ export enum SelectionDirection {
43
+ /** The caret is at the end of the selection (default / left-to-right). */
44
+ Forward = 'f',
45
+ /** The caret is at the start of the selection (right-to-left). */
46
+ Backward = 'b',
47
+ }
48
+
39
49
  /**
40
50
  * Converts WordPress block editor selection to a SelectionState.
41
51
  *
@@ -44,16 +54,20 @@ export enum SelectionType {
44
54
  * differ between the block-editor store and the Yjs document (e.g. in "Show
45
55
  * Template" mode).
46
56
  *
47
- * @param selectionStart - The start position of the selection
48
- * @param selectionEnd - The end position of the selection
49
- * @param yDoc - The Yjs document
57
+ * @param selectionStart - The start position of the selection
58
+ * @param selectionEnd - The end position of the selection
59
+ * @param yDoc - The Yjs document
60
+ * @param options - Optional parameters
61
+ * @param options.selectionDirection - The direction of the selection (forward or backward)
50
62
  * @return The SelectionState
51
63
  */
52
64
  export function getSelectionState(
53
65
  selectionStart: WPBlockSelection,
54
66
  selectionEnd: WPBlockSelection,
55
- yDoc: Y.Doc
67
+ yDoc: Y.Doc,
68
+ options?: { selectionDirection?: SelectionDirection }
56
69
  ): SelectionState {
70
+ const { selectionDirection } = options ?? {};
57
71
  const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY );
58
72
  const yBlocks = ymap.get( 'blocks' );
59
73
 
@@ -122,6 +136,7 @@ export function getSelectionState(
122
136
  type: SelectionType.SelectionInOneBlock,
123
137
  cursorStartPosition,
124
138
  cursorEndPosition,
139
+ selectionDirection,
125
140
  };
126
141
  }
127
142
 
@@ -137,6 +152,7 @@ export function getSelectionState(
137
152
  type: SelectionType.SelectionInMultipleBlocks,
138
153
  cursorStartPosition,
139
154
  cursorEndPosition,
155
+ selectionDirection,
140
156
  };
141
157
  }
142
158
 
@@ -171,7 +187,7 @@ function getCursorPosition(
171
187
 
172
188
  const relativePosition = Y.createRelativePositionFromTypeIndex(
173
189
  currentYText,
174
- selection.offset
190
+ richTextOffsetToHtmlIndex( currentYText.toString(), selection.offset )
175
191
  );
176
192
 
177
193
  return {
@@ -315,7 +331,9 @@ export function areSelectionsStatesEqual(
315
331
  areCursorPositionsEqual(
316
332
  selection1.cursorEndPosition,
317
333
  ( selection2 as SelectionInOneBlock ).cursorEndPosition
318
- )
334
+ ) &&
335
+ selection1.selectionDirection ===
336
+ ( selection2 as SelectionInOneBlock ).selectionDirection
319
337
  );
320
338
 
321
339
  case SelectionType.SelectionInMultipleBlocks:
@@ -329,7 +347,10 @@ export function areSelectionsStatesEqual(
329
347
  selection1.cursorEndPosition,
330
348
  ( selection2 as SelectionInMultipleBlocks )
331
349
  .cursorEndPosition
332
- )
350
+ ) &&
351
+ selection1.selectionDirection ===
352
+ ( selection2 as SelectionInMultipleBlocks )
353
+ .selectionDirection
333
354
  );
334
355
  case SelectionType.WholeBlock:
335
356
  return Y.compareRelativePositions(