@wordpress/core-data 7.46.0 → 7.48.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.
- package/CHANGELOG.md +4 -0
- package/README.md +0 -27
- package/build/actions.cjs +0 -19
- package/build/actions.cjs.map +2 -2
- package/build/awareness/block-lookup.cjs +13 -0
- package/build/awareness/block-lookup.cjs.map +2 -2
- package/build/awareness/post-editor-awareness.cjs +21 -9
- package/build/awareness/post-editor-awareness.cjs.map +2 -2
- package/build/hooks/use-entity-block-editor.cjs +4 -4
- package/build/hooks/use-entity-block-editor.cjs.map +2 -2
- package/build/hooks/use-post-editor-awareness-state.cjs +2 -1
- package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
- package/build/hooks/use-resource-permissions.cjs +3 -5
- package/build/hooks/use-resource-permissions.cjs.map +2 -2
- package/build/index.cjs +0 -6
- package/build/index.cjs.map +2 -2
- package/build/parsed-blocks-cache.cjs +36 -0
- package/build/parsed-blocks-cache.cjs.map +7 -0
- package/build/private-actions.cjs +25 -2
- package/build/private-actions.cjs.map +2 -2
- package/build/private-apis.cjs +9 -5
- package/build/private-apis.cjs.map +3 -3
- package/build/private-selectors.cjs +15 -0
- package/build/private-selectors.cjs.map +2 -2
- package/build/resolvers.cjs +12 -2
- package/build/resolvers.cjs.map +2 -2
- package/build/selectors.cjs +0 -15
- package/build/selectors.cjs.map +2 -2
- package/build/sync.cjs +5 -0
- package/build/sync.cjs.map +2 -2
- package/build/types.cjs +0 -16
- package/build/types.cjs.map +3 -3
- package/build/utils/block-selection-history.cjs +5 -4
- package/build/utils/block-selection-history.cjs.map +2 -2
- package/build/utils/crdt-blocks.cjs +3 -0
- package/build/utils/crdt-blocks.cjs.map +2 -2
- package/build/utils/crdt-user-selections.cjs +10 -2
- package/build/utils/crdt-user-selections.cjs.map +3 -3
- package/build/utils/crdt-utils.cjs +23 -0
- package/build/utils/crdt-utils.cjs.map +2 -2
- package/build/utils/crdt.cjs +28 -4
- package/build/utils/crdt.cjs.map +2 -2
- package/build-module/actions.mjs +0 -18
- package/build-module/actions.mjs.map +2 -2
- package/build-module/awareness/block-lookup.mjs +12 -0
- package/build-module/awareness/block-lookup.mjs.map +2 -2
- package/build-module/awareness/post-editor-awareness.mjs +26 -9
- package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
- package/build-module/hooks/use-entity-block-editor.mjs +2 -2
- package/build-module/hooks/use-entity-block-editor.mjs.map +2 -2
- package/build-module/hooks/use-post-editor-awareness-state.mjs +2 -1
- package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
- package/build-module/hooks/use-resource-permissions.mjs +3 -5
- package/build-module/hooks/use-resource-permissions.mjs.map +2 -2
- package/build-module/index.mjs +0 -4
- package/build-module/index.mjs.map +2 -2
- package/build-module/parsed-blocks-cache.mjs +10 -0
- package/build-module/parsed-blocks-cache.mjs.map +7 -0
- package/build-module/private-actions.mjs +23 -1
- package/build-module/private-actions.mjs.map +2 -2
- package/build-module/private-apis.mjs +12 -5
- package/build-module/private-apis.mjs.map +3 -3
- package/build-module/private-selectors.mjs +14 -0
- package/build-module/private-selectors.mjs.map +2 -2
- package/build-module/resolvers.mjs +12 -2
- package/build-module/resolvers.mjs.map +2 -2
- package/build-module/selectors.mjs +0 -14
- package/build-module/selectors.mjs.map +2 -2
- package/build-module/sync.mjs +4 -0
- package/build-module/sync.mjs.map +2 -2
- package/build-module/types.mjs +0 -9
- package/build-module/types.mjs.map +4 -4
- package/build-module/utils/block-selection-history.mjs +6 -4
- package/build-module/utils/block-selection-history.mjs.map +2 -2
- package/build-module/utils/crdt-blocks.mjs +3 -0
- package/build-module/utils/crdt-blocks.mjs.map +2 -2
- package/build-module/utils/crdt-user-selections.mjs +10 -2
- package/build-module/utils/crdt-user-selections.mjs.map +3 -3
- package/build-module/utils/crdt-utils.mjs +22 -0
- package/build-module/utils/crdt-utils.mjs.map +2 -2
- package/build-module/utils/crdt.mjs +32 -5
- package/build-module/utils/crdt.mjs.map +2 -2
- package/build-types/actions.d.ts +0 -11
- package/build-types/actions.d.ts.map +1 -1
- package/build-types/awareness/block-lookup.d.ts +12 -0
- package/build-types/awareness/block-lookup.d.ts.map +1 -1
- package/build-types/awareness/post-editor-awareness.d.ts +2 -5
- package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
- package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
- package/build-types/hooks/use-resource-permissions.d.ts.map +1 -1
- package/build-types/index.d.ts +0 -8
- package/build-types/index.d.ts.map +1 -1
- package/build-types/parsed-blocks-cache.d.ts +10 -0
- package/build-types/parsed-blocks-cache.d.ts.map +1 -0
- package/build-types/private-actions.d.ts +12 -0
- package/build-types/private-actions.d.ts.map +1 -1
- package/build-types/private-apis.d.ts +20 -0
- package/build-types/private-apis.d.ts.map +1 -1
- package/build-types/private-selectors.d.ts +10 -0
- package/build-types/private-selectors.d.ts.map +1 -1
- package/build-types/queried-data/selectors.d.ts +1 -1
- package/build-types/queried-data/selectors.d.ts.map +1 -1
- package/build-types/resolvers.d.ts.map +1 -1
- package/build-types/selectors.d.ts +0 -9
- package/build-types/selectors.d.ts.map +1 -1
- package/build-types/sync.d.ts +6 -0
- package/build-types/sync.d.ts.map +1 -1
- package/build-types/types.d.ts +3 -10
- package/build-types/types.d.ts.map +1 -1
- package/build-types/utils/block-selection-history.d.ts.map +1 -1
- package/build-types/utils/crdt-blocks.d.ts.map +1 -1
- package/build-types/utils/crdt-user-selections.d.ts +10 -1
- package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
- package/build-types/utils/crdt-utils.d.ts +11 -0
- package/build-types/utils/crdt-utils.d.ts.map +1 -1
- package/build-types/utils/crdt.d.ts +5 -1
- package/build-types/utils/crdt.d.ts.map +1 -1
- package/package.json +18 -18
- package/src/actions.js +0 -29
- package/src/awareness/block-lookup.ts +34 -0
- package/src/awareness/post-editor-awareness.ts +32 -14
- package/src/awareness/test/block-lookup.ts +70 -0
- package/src/awareness/test/post-editor-awareness.ts +243 -0
- package/src/hooks/test/use-post-editor-awareness-state.ts +3 -0
- package/src/hooks/test/use-resource-permissions.js +57 -0
- package/src/hooks/use-entity-block-editor.js +2 -2
- package/src/hooks/use-post-editor-awareness-state.ts +1 -0
- package/src/hooks/use-resource-permissions.ts +5 -7
- package/src/index.js +0 -7
- package/src/parsed-blocks-cache.js +12 -0
- package/src/private-actions.js +34 -0
- package/src/{private-apis.js → private-apis.ts} +13 -3
- package/src/private-selectors.ts +33 -0
- package/src/resolvers.js +27 -5
- package/src/selectors.ts +0 -32
- package/src/sync.ts +9 -0
- package/src/test/resolvers.js +13 -7
- package/src/types.ts +16 -11
- package/src/utils/block-selection-history.ts +10 -7
- package/src/utils/crdt-blocks.ts +24 -0
- package/src/utils/crdt-user-selections.ts +15 -2
- package/src/utils/crdt-utils.ts +41 -0
- package/src/utils/crdt.ts +83 -10
- package/src/utils/test/block-selection-history.test.ts +42 -0
- package/src/utils/test/crdt-blocks.ts +37 -0
- package/src/utils/test/crdt-user-selections.ts +39 -0
- package/src/utils/test/crdt-utils.ts +52 -0
- package/src/utils/test/crdt.ts +208 -2
package/src/utils/crdt.ts
CHANGED
|
@@ -6,7 +6,11 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* WordPress dependencies
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
__unstableSerializeAndClean,
|
|
11
|
+
parse,
|
|
12
|
+
type Block as WPBlock,
|
|
13
|
+
} from '@wordpress/blocks';
|
|
10
14
|
import {
|
|
11
15
|
type CRDTDoc,
|
|
12
16
|
type ObjectData,
|
|
@@ -46,10 +50,16 @@ import {
|
|
|
46
50
|
type YMapWrap,
|
|
47
51
|
} from './crdt-utils';
|
|
48
52
|
|
|
53
|
+
// A function that derives content from blocks. Two callers produce this:
|
|
54
|
+
// `useEntityBlockEditor` reads blocks from its argument (so the optional arg
|
|
55
|
+
// lets it accept whatever caller is invoked with), and the receiver-side
|
|
56
|
+
// injection in this file captures blocks in a closure and ignores the arg.
|
|
57
|
+
type ContentFromBlocksFn = ( args?: { blocks: Block[] } ) => string;
|
|
58
|
+
|
|
49
59
|
// Changes that can be applied to a post entity record.
|
|
50
60
|
export type PostChanges = Partial< Post > & {
|
|
51
61
|
blocks?: Block[];
|
|
52
|
-
content?: Post[ 'content' ] | string;
|
|
62
|
+
content?: Post[ 'content' ] | string | ContentFromBlocksFn;
|
|
53
63
|
excerpt?: Post[ 'excerpt' ] | string;
|
|
54
64
|
selection?: WPSelection;
|
|
55
65
|
title?: Post[ 'title' ] | string;
|
|
@@ -138,15 +148,39 @@ export function applyPostChangesToCRDTDoc(
|
|
|
138
148
|
|
|
139
149
|
const newValue = changes[ key ];
|
|
140
150
|
|
|
141
|
-
// Cannot serialize function values, so cannot sync them.
|
|
151
|
+
// Cannot serialize function values, so cannot sync them. `content` is
|
|
152
|
+
// often passed as a lazy serializer by `useEntityBlockEditor`; the
|
|
153
|
+
// receiver re-derives it from the synced blocks (see
|
|
154
|
+
// getPostChangesFromCRDTDoc), so dropping it here is intentional.
|
|
142
155
|
if ( 'function' === typeof newValue ) {
|
|
143
156
|
return;
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
switch ( key ) {
|
|
147
160
|
case 'blocks': {
|
|
161
|
+
// Block changes from typing are bundled with a 'selection' update.
|
|
162
|
+
// Use the resulting cursor position for block merging.
|
|
163
|
+
const newCursorPosition = parseCursorSelection(
|
|
164
|
+
changes.selection
|
|
165
|
+
);
|
|
166
|
+
|
|
148
167
|
// Blocks are undefined when they need to be re-parsed from content.
|
|
149
|
-
|
|
168
|
+
// When new content is also part of this change (e.g. the Code
|
|
169
|
+
// Editor dispatching `{ content, blocks: undefined }` on every
|
|
170
|
+
// keystroke), derive blocks from content so the merge keeps
|
|
171
|
+
// stable YBlock identities for unchanged blocks.
|
|
172
|
+
|
|
173
|
+
const rawContent = getRawValue( changes.content );
|
|
174
|
+
if ( ! newValue && typeof rawContent === 'string' ) {
|
|
175
|
+
// We have no blocks but an updated content string.
|
|
176
|
+
mergeContentWithoutBlocks(
|
|
177
|
+
ymap,
|
|
178
|
+
rawContent,
|
|
179
|
+
newCursorPosition
|
|
180
|
+
);
|
|
181
|
+
break;
|
|
182
|
+
} else if ( ! newValue ) {
|
|
183
|
+
// We have an update containing empty blocks and content.
|
|
150
184
|
// Set to undefined instead of deleting the key. This is important
|
|
151
185
|
// since we iterate over the Y.Map keys in getPostChangesFromCRDTDoc.
|
|
152
186
|
ymap.set( key, undefined );
|
|
@@ -161,12 +195,6 @@ export function applyPostChangesToCRDTDoc(
|
|
|
161
195
|
ymap.set( key, currentBlocks );
|
|
162
196
|
}
|
|
163
197
|
|
|
164
|
-
// Block changes from typing are bundled with a 'selection' update.
|
|
165
|
-
// Pass the resulting cursor position to the mergeCrdtBlocks function.
|
|
166
|
-
const newCursorPosition = parseCursorSelection(
|
|
167
|
-
changes.selection
|
|
168
|
-
);
|
|
169
|
-
|
|
170
198
|
// Merge blocks does not need `setValue` because it is operating on a
|
|
171
199
|
// Yjs type that is already in the Y.Doc.
|
|
172
200
|
mergeCrdtBlocks( currentBlocks, newValue, newCursorPosition );
|
|
@@ -263,6 +291,36 @@ export function applyPostChangesToCRDTDoc(
|
|
|
263
291
|
}
|
|
264
292
|
}
|
|
265
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Derive blocks from a raw content string and merge them into the post's
|
|
296
|
+
* blocks Y.Array. Used when a caller dispatches a change with `blocks:
|
|
297
|
+
* undefined` alongside new content, most notably the Code Editor's
|
|
298
|
+
* per-keystroke dispatch.
|
|
299
|
+
*
|
|
300
|
+
* @param ymap The post's root Y.Map.
|
|
301
|
+
* @param rawContent The raw HTML content to parse.
|
|
302
|
+
* @param cursorPosition Cursor position derived from the change's selection,
|
|
303
|
+
* used by mergeCrdtBlocks for rich-text cursor hints.
|
|
304
|
+
*/
|
|
305
|
+
function mergeContentWithoutBlocks(
|
|
306
|
+
ymap: YMapWrap< YPostRecord >,
|
|
307
|
+
rawContent: string,
|
|
308
|
+
cursorPosition: MergeCursorPosition
|
|
309
|
+
): void {
|
|
310
|
+
let currentBlocks = ymap.get( 'blocks' );
|
|
311
|
+
|
|
312
|
+
if ( ! ( currentBlocks instanceof Y.Array ) ) {
|
|
313
|
+
currentBlocks = new Y.Array< YBlock >();
|
|
314
|
+
ymap.set( 'blocks', currentBlocks );
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
mergeCrdtBlocks(
|
|
318
|
+
currentBlocks,
|
|
319
|
+
parse( rawContent ) as Block[],
|
|
320
|
+
cursorPosition
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
266
324
|
/**
|
|
267
325
|
* Only returns a selection object if it describes a selection within a block, with
|
|
268
326
|
* a cursor inside a RichText field associated with one of that block’s attributes.
|
|
@@ -431,6 +489,21 @@ export function getPostChangesFromCRDTDoc(
|
|
|
431
489
|
);
|
|
432
490
|
}
|
|
433
491
|
|
|
492
|
+
// When blocks changed but content didn't (the sender internally used a lazy
|
|
493
|
+
// serializer function), inject a closure that captures the synced blocks
|
|
494
|
+
// and serializes them on demand. Mirrors what useEntityBlockEditor does
|
|
495
|
+
// locally. A fresh function on every persistent edit marks the entity
|
|
496
|
+
// dirty (so the save button reactivates for peers), while serialization
|
|
497
|
+
// stays lazy (only runs when getEditedPostContent reads it). The closure
|
|
498
|
+
// captures `capturedBlocks` so the right content is returned even if the
|
|
499
|
+
// caller later clears `record.blocks` (e.g. the Code Editor re-parsing
|
|
500
|
+
// from content).
|
|
501
|
+
if ( changes.blocks && ! changes.content ) {
|
|
502
|
+
const capturedBlocks = changes.blocks;
|
|
503
|
+
changes.content = () =>
|
|
504
|
+
__unstableSerializeAndClean( capturedBlocks as WPBlock[] );
|
|
505
|
+
}
|
|
506
|
+
|
|
434
507
|
// Meta changes must be merged with the edited record since not all meta
|
|
435
508
|
// properties are synced.
|
|
436
509
|
if ( 'object' === typeof changes.meta ) {
|
|
@@ -38,6 +38,15 @@ function createTestDoc() {
|
|
|
38
38
|
block2.set( 'clientId', 'block-2' );
|
|
39
39
|
const block2Attrs = new Y.Map();
|
|
40
40
|
block2Attrs.set( 'content', new Y.Text( 'Second block' ) );
|
|
41
|
+
const body = new Y.Array();
|
|
42
|
+
const row = new Y.Map();
|
|
43
|
+
const cells = new Y.Array();
|
|
44
|
+
const cell = new Y.Map();
|
|
45
|
+
cell.set( 'content', new Y.Text( 'Cell text' ) );
|
|
46
|
+
cells.push( [ cell ] );
|
|
47
|
+
row.set( 'cells', cells );
|
|
48
|
+
body.push( [ row ] );
|
|
49
|
+
block2Attrs.set( 'body', body );
|
|
41
50
|
block2.set( 'attributes', block2Attrs );
|
|
42
51
|
block2.set( 'innerBlocks', new Y.Array() );
|
|
43
52
|
blocks.push( [ block2 ] );
|
|
@@ -210,6 +219,39 @@ describe( 'BlockSelectionHistory', () => {
|
|
|
210
219
|
const endPosition = fullSelection.end as YRelativeSelection;
|
|
211
220
|
expect( endPosition.offset ).toBe( 0 );
|
|
212
221
|
} );
|
|
222
|
+
|
|
223
|
+
test( 'should convert nested rich-text attribute paths to relative positions', () => {
|
|
224
|
+
const selection = createSelection( {
|
|
225
|
+
clientId: 'block-2',
|
|
226
|
+
attributeKey: 'body.0.cells.0.content',
|
|
227
|
+
offset: 4,
|
|
228
|
+
} );
|
|
229
|
+
|
|
230
|
+
history.updateSelection( selection );
|
|
231
|
+
|
|
232
|
+
const selectionHistory = history.getSelectionHistory();
|
|
233
|
+
expect( selectionHistory.length ).toBe( 1 );
|
|
234
|
+
|
|
235
|
+
const fullSelection = selectionHistory[ 0 ];
|
|
236
|
+
expect( fullSelection.start.type ).toBe(
|
|
237
|
+
YSelectionType.RelativeSelection
|
|
238
|
+
);
|
|
239
|
+
expect( fullSelection.end.type ).toBe(
|
|
240
|
+
YSelectionType.RelativeSelection
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const startPosition = fullSelection.start as YRelativeSelection;
|
|
244
|
+
const endPosition = fullSelection.end as YRelativeSelection;
|
|
245
|
+
|
|
246
|
+
expect( startPosition.attributeKey ).toBe(
|
|
247
|
+
'body.0.cells.0.content'
|
|
248
|
+
);
|
|
249
|
+
expect( startPosition.offset ).toBe( 4 );
|
|
250
|
+
expect( startPosition.relativePosition ).toBeDefined();
|
|
251
|
+
expect( endPosition.attributeKey ).toBe( 'body.0.cells.0.content' );
|
|
252
|
+
expect( endPosition.offset ).toBe( 4 );
|
|
253
|
+
expect( endPosition.relativePosition ).toBeDefined();
|
|
254
|
+
} );
|
|
213
255
|
} );
|
|
214
256
|
|
|
215
257
|
describe( 'updateSelection with block positions', () => {
|
|
@@ -196,6 +196,43 @@ describe( 'crdt-blocks', () => {
|
|
|
196
196
|
expect( content.toString() ).toBe( 'Updated content' );
|
|
197
197
|
} );
|
|
198
198
|
|
|
199
|
+
it( 'preserves the local clientId when an updated block arrives with a different clientId', () => {
|
|
200
|
+
// Simulates the Code Editor flow: the sender re-parses raw HTML on
|
|
201
|
+
// every keystroke, which mints a fresh clientId for every block.
|
|
202
|
+
// The Y.Doc's clientId should stay stable so remote peers don't
|
|
203
|
+
// remount the block (and any embed iframe within it).
|
|
204
|
+
const initialBlocks: Block[] = [
|
|
205
|
+
{
|
|
206
|
+
name: 'core/paragraph',
|
|
207
|
+
attributes: { content: 'Initial content' },
|
|
208
|
+
innerBlocks: [],
|
|
209
|
+
clientId: 'stable-id',
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
mergeCrdtBlocks( yblocks, initialBlocks, null );
|
|
214
|
+
expect( yblocks.get( 0 ).get( 'clientId' ) ).toBe( 'stable-id' );
|
|
215
|
+
|
|
216
|
+
const reparsedBlocks: Block[] = [
|
|
217
|
+
{
|
|
218
|
+
name: 'core/paragraph',
|
|
219
|
+
attributes: { content: 'Updated content' },
|
|
220
|
+
innerBlocks: [],
|
|
221
|
+
clientId: 'freshly-parsed-id',
|
|
222
|
+
},
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
mergeCrdtBlocks( yblocks, reparsedBlocks, null );
|
|
226
|
+
|
|
227
|
+
expect( yblocks.length ).toBe( 1 );
|
|
228
|
+
const block = yblocks.get( 0 );
|
|
229
|
+
expect( block.get( 'clientId' ) ).toBe( 'stable-id' );
|
|
230
|
+
const content = (
|
|
231
|
+
block.get( 'attributes' ) as YBlockAttributes
|
|
232
|
+
).get( 'content' ) as Y.Text;
|
|
233
|
+
expect( content.toString() ).toBe( 'Updated content' );
|
|
234
|
+
} );
|
|
235
|
+
|
|
199
236
|
it( 'deletes blocks that are removed', () => {
|
|
200
237
|
const initialBlocks: Block[] = [
|
|
201
238
|
{
|
|
@@ -396,6 +396,15 @@ function createTestDocWithBlocks() {
|
|
|
396
396
|
block2.set( 'clientId', 'block-2' );
|
|
397
397
|
const block2Attrs = new Y.Map();
|
|
398
398
|
block2Attrs.set( 'content', new Y.Text( 'Second block content' ) );
|
|
399
|
+
const body = new Y.Array();
|
|
400
|
+
const row = new Y.Map();
|
|
401
|
+
const cells = new Y.Array();
|
|
402
|
+
const cell = new Y.Map();
|
|
403
|
+
cell.set( 'content', new Y.Text( 'Cell text' ) );
|
|
404
|
+
cells.push( [ cell ] );
|
|
405
|
+
row.set( 'cells', cells );
|
|
406
|
+
body.push( [ row ] );
|
|
407
|
+
block2Attrs.set( 'body', body );
|
|
399
408
|
block2.set( 'attributes', block2Attrs );
|
|
400
409
|
block2.set( 'innerBlocks', new Y.Array() );
|
|
401
410
|
blocks.push( [ block2 ] );
|
|
@@ -529,6 +538,9 @@ describe( 'getSelectionState', () => {
|
|
|
529
538
|
expect(
|
|
530
539
|
( result as SelectionCursor ).cursorPosition.absoluteOffset
|
|
531
540
|
).toBe( 5 );
|
|
541
|
+
expect(
|
|
542
|
+
( result as SelectionCursor ).cursorPosition.attributeKey
|
|
543
|
+
).toBe( 'content' );
|
|
532
544
|
} );
|
|
533
545
|
|
|
534
546
|
test( 'returns Cursor at start of block (offset 0)', () => {
|
|
@@ -555,6 +567,33 @@ describe( 'getSelectionState', () => {
|
|
|
555
567
|
).toBe( 0 );
|
|
556
568
|
} );
|
|
557
569
|
|
|
570
|
+
test( 'returns Cursor for a nested rich-text attribute path', () => {
|
|
571
|
+
const selectionStart: WPBlockSelection = {
|
|
572
|
+
clientId: 'block-2',
|
|
573
|
+
attributeKey: 'body.0.cells.0.content',
|
|
574
|
+
offset: 4,
|
|
575
|
+
};
|
|
576
|
+
const selectionEnd: WPBlockSelection = {
|
|
577
|
+
clientId: 'block-2',
|
|
578
|
+
attributeKey: 'body.0.cells.0.content',
|
|
579
|
+
offset: 4,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const result = getSelectionState(
|
|
583
|
+
selectionStart,
|
|
584
|
+
selectionEnd,
|
|
585
|
+
testDoc
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
expect( result.type ).toBe( SelectionType.Cursor );
|
|
589
|
+
expect(
|
|
590
|
+
( result as SelectionCursor ).cursorPosition.absoluteOffset
|
|
591
|
+
).toBe( 4 );
|
|
592
|
+
expect(
|
|
593
|
+
( result as SelectionCursor ).cursorPosition.attributeKey
|
|
594
|
+
).toBe( 'body.0.cells.0.content' );
|
|
595
|
+
} );
|
|
596
|
+
|
|
558
597
|
test( 'returns None when block does not exist', () => {
|
|
559
598
|
const selectionStart: WPBlockSelection = {
|
|
560
599
|
clientId: 'non-existent-block',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* External dependencies
|
|
3
3
|
*/
|
|
4
4
|
import { describe, expect, it } from '@jest/globals';
|
|
5
|
+
import { Y } from '@wordpress/sync';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Internal dependencies
|
|
@@ -9,6 +10,7 @@ import { describe, expect, it } from '@jest/globals';
|
|
|
9
10
|
import {
|
|
10
11
|
asHtmlStringIndex,
|
|
11
12
|
asRichTextOffset,
|
|
13
|
+
getYTextByAttributeKey,
|
|
12
14
|
htmlIndexToRichTextOffset as typedHtmlIndexToRichTextOffset,
|
|
13
15
|
richTextOffsetToHtmlIndex as typedRichTextOffsetToHtmlIndex,
|
|
14
16
|
} from '../crdt-utils';
|
|
@@ -27,6 +29,56 @@ function richTextOffsetToHtmlIndex( html: string, richTextOffset: number ) {
|
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function createAttachedAttributes(): Y.Map< unknown > {
|
|
33
|
+
const ydoc = new Y.Doc();
|
|
34
|
+
const root = ydoc.getMap( 'test' );
|
|
35
|
+
const attributes = new Y.Map< unknown >();
|
|
36
|
+
root.set( 'attributes', attributes );
|
|
37
|
+
return attributes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe( 'getYTextByAttributeKey', () => {
|
|
41
|
+
it( 'returns a top-level rich-text attribute', () => {
|
|
42
|
+
const attributes = createAttachedAttributes();
|
|
43
|
+
const text = new Y.Text( 'Top level' );
|
|
44
|
+
attributes.set( 'content', text );
|
|
45
|
+
|
|
46
|
+
expect( getYTextByAttributeKey( attributes, 'content' ) ).toBe( text );
|
|
47
|
+
} );
|
|
48
|
+
|
|
49
|
+
it( 'returns a nested rich-text attribute by dot path', () => {
|
|
50
|
+
const attributes = createAttachedAttributes();
|
|
51
|
+
const body = new Y.Array< Y.Map< unknown > >();
|
|
52
|
+
const row = new Y.Map< unknown >();
|
|
53
|
+
const cells = new Y.Array< Y.Map< unknown > >();
|
|
54
|
+
const cell = new Y.Map< unknown >();
|
|
55
|
+
const text = new Y.Text( 'Cell text' );
|
|
56
|
+
|
|
57
|
+
cell.set( 'content', text );
|
|
58
|
+
cells.push( [ cell ] );
|
|
59
|
+
row.set( 'cells', cells );
|
|
60
|
+
body.push( [ row ] );
|
|
61
|
+
attributes.set( 'body', body );
|
|
62
|
+
|
|
63
|
+
expect(
|
|
64
|
+
getYTextByAttributeKey( attributes, 'body.0.cells.0.content' )
|
|
65
|
+
).toBe( text );
|
|
66
|
+
} );
|
|
67
|
+
|
|
68
|
+
it( 'returns null for invalid array path segments', () => {
|
|
69
|
+
const attributes = createAttachedAttributes();
|
|
70
|
+
const body = new Y.Array< Y.Map< unknown > >();
|
|
71
|
+
attributes.set( 'body', body );
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
getYTextByAttributeKey( attributes, 'body.01.cells.0.content' )
|
|
75
|
+
).toBeNull();
|
|
76
|
+
expect(
|
|
77
|
+
getYTextByAttributeKey( attributes, 'body.-1.cells.0.content' )
|
|
78
|
+
).toBeNull();
|
|
79
|
+
} );
|
|
80
|
+
} );
|
|
81
|
+
|
|
30
82
|
describe( 'htmlIndexToRichTextOffset', () => {
|
|
31
83
|
it( 'returns the index unchanged when there are no tags', () => {
|
|
32
84
|
expect( htmlIndexToRichTextOffset( 'hello world', 5 ) ).toBe( 5 );
|
package/src/utils/test/crdt.ts
CHANGED
|
@@ -10,6 +10,9 @@ import { describe, expect, it, jest, beforeEach } from '@jest/globals';
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Mock getBlockTypes so CRDT merging can identify rich-text attributes.
|
|
13
|
+
* Also stub __unstableSerializeAndClean so we can assert how it's invoked
|
|
14
|
+
* (the real implementation returns "" without registered block types, which
|
|
15
|
+
* isn't useful for asserting closure-capture behavior).
|
|
13
16
|
*/
|
|
14
17
|
jest.mock( '@wordpress/blocks', () => {
|
|
15
18
|
const actual = jest.requireActual( '@wordpress/blocks' ) as Record<
|
|
@@ -43,12 +46,19 @@ jest.mock( '@wordpress/blocks', () => {
|
|
|
43
46
|
},
|
|
44
47
|
},
|
|
45
48
|
],
|
|
49
|
+
// Mocked so tests can control what the Code Editor sync path "parses"
|
|
50
|
+
// from raw content without needing real block-type registration.
|
|
51
|
+
parse: jest.fn( () => [] ),
|
|
52
|
+
__unstableSerializeAndClean: jest.fn(
|
|
53
|
+
( blocks: unknown[] ) => `serialized:${ blocks?.length ?? 0 }`
|
|
54
|
+
),
|
|
46
55
|
};
|
|
47
56
|
} );
|
|
48
57
|
|
|
49
58
|
/**
|
|
50
59
|
* WordPress dependencies
|
|
51
60
|
*/
|
|
61
|
+
import { parse } from '@wordpress/blocks';
|
|
52
62
|
import { RichTextData } from '@wordpress/rich-text';
|
|
53
63
|
|
|
54
64
|
/**
|
|
@@ -63,7 +73,7 @@ import {
|
|
|
63
73
|
type PostChanges,
|
|
64
74
|
type YPostRecord,
|
|
65
75
|
} from '../crdt';
|
|
66
|
-
import type { YBlock, YBlockRecord, YBlocks } from '../crdt-blocks';
|
|
76
|
+
import type { Block, YBlock, YBlockRecord, YBlocks } from '../crdt-blocks';
|
|
67
77
|
import { updateSelectionHistory } from '../crdt-selection';
|
|
68
78
|
import { createYMap, getRootMap, type YMapWrap } from '../crdt-utils';
|
|
69
79
|
import type { Post } from '../../entity-types';
|
|
@@ -126,9 +136,12 @@ describe( 'crdt', () => {
|
|
|
126
136
|
beforeEach( () => {
|
|
127
137
|
doc = new Y.Doc();
|
|
128
138
|
jest.clearAllMocks();
|
|
139
|
+
jest.useFakeTimers();
|
|
129
140
|
} );
|
|
130
141
|
|
|
131
142
|
afterEach( () => {
|
|
143
|
+
jest.runAllTimers();
|
|
144
|
+
jest.useRealTimers();
|
|
132
145
|
doc.destroy();
|
|
133
146
|
} );
|
|
134
147
|
|
|
@@ -286,7 +299,7 @@ describe( 'crdt', () => {
|
|
|
286
299
|
expect( blocks ).toBeInstanceOf( Y.Array );
|
|
287
300
|
} );
|
|
288
301
|
|
|
289
|
-
it( 'sets blocks to undefined when blocks value is undefined', () => {
|
|
302
|
+
it( 'sets blocks to undefined when blocks value is undefined and no content is provided', () => {
|
|
290
303
|
// First, set some blocks.
|
|
291
304
|
map.set( 'blocks', new Y.Array< YBlock >() );
|
|
292
305
|
|
|
@@ -301,6 +314,86 @@ describe( 'crdt', () => {
|
|
|
301
314
|
expect( map.get( 'blocks' ) ).toBeUndefined();
|
|
302
315
|
} );
|
|
303
316
|
|
|
317
|
+
it( 'parses content into blocks when blocks=undefined is paired with new content', () => {
|
|
318
|
+
// Pre-populate the Y.Doc with two stable blocks. Simulates the
|
|
319
|
+
// state after the initial sync: peers share the same blocks Y.Array
|
|
320
|
+
// with stable clientIds on every YBlock.
|
|
321
|
+
applyPostChangesToCRDTDoc(
|
|
322
|
+
doc,
|
|
323
|
+
{
|
|
324
|
+
blocks: [
|
|
325
|
+
{
|
|
326
|
+
name: 'core/paragraph',
|
|
327
|
+
attributes: { content: 'Hello' },
|
|
328
|
+
innerBlocks: [],
|
|
329
|
+
clientId: 'stable-first',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
name: 'core/paragraph',
|
|
333
|
+
attributes: { content: 'World' },
|
|
334
|
+
innerBlocks: [],
|
|
335
|
+
clientId: 'stable-second',
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
} as PostChanges,
|
|
339
|
+
defaultSyncedProperties
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// The Code Editor flow: dispatch `{ content, blocks: undefined }`
|
|
343
|
+
// when the user types. The new HTML edits the second paragraph
|
|
344
|
+
// only. `parse()` is mocked to return blocks with freshly minted
|
|
345
|
+
// clientIds — the sync layer must not let those overwrite the
|
|
346
|
+
// stable clientIds already in the Y.Array.
|
|
347
|
+
( parse as jest.Mock ).mockReturnValueOnce( [
|
|
348
|
+
{
|
|
349
|
+
name: 'core/paragraph',
|
|
350
|
+
attributes: { content: 'Hello' },
|
|
351
|
+
innerBlocks: [],
|
|
352
|
+
clientId: 'fresh-first',
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: 'core/paragraph',
|
|
356
|
+
attributes: { content: 'World!' },
|
|
357
|
+
innerBlocks: [],
|
|
358
|
+
clientId: 'fresh-second',
|
|
359
|
+
},
|
|
360
|
+
] );
|
|
361
|
+
|
|
362
|
+
applyPostChangesToCRDTDoc(
|
|
363
|
+
doc,
|
|
364
|
+
{
|
|
365
|
+
content:
|
|
366
|
+
'<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->' +
|
|
367
|
+
'<!-- wp:paragraph --><p>World!</p><!-- /wp:paragraph -->',
|
|
368
|
+
blocks: undefined,
|
|
369
|
+
} as PostChanges,
|
|
370
|
+
defaultSyncedProperties
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const yblocks = map.get( 'blocks' );
|
|
374
|
+
expect( yblocks ).toBeInstanceOf( Y.Array );
|
|
375
|
+
const blocksArray = yblocks as YBlocks;
|
|
376
|
+
expect( blocksArray.length ).toBe( 2 );
|
|
377
|
+
|
|
378
|
+
// Both clientIds must be preserved: the unchanged first block via
|
|
379
|
+
// the left-right diff sweep, the edited second block via the
|
|
380
|
+
// explicit clientId-skip in the update loop.
|
|
381
|
+
expect( blocksArray.get( 0 ).get( 'clientId' ) ).toBe(
|
|
382
|
+
'stable-first'
|
|
383
|
+
);
|
|
384
|
+
expect( blocksArray.get( 1 ).get( 'clientId' ) ).toBe(
|
|
385
|
+
'stable-second'
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// The second block's content reflects the edit.
|
|
389
|
+
const updatedContent = (
|
|
390
|
+
blocksArray
|
|
391
|
+
.get( 1 )
|
|
392
|
+
.get( 'attributes' ) as unknown as YMapWrap< YBlockRecord >
|
|
393
|
+
).get( 'content' ) as Y.Text;
|
|
394
|
+
expect( updatedContent.toString() ).toBe( 'World!' );
|
|
395
|
+
} );
|
|
396
|
+
|
|
304
397
|
it( 'syncs content as Y.Text', () => {
|
|
305
398
|
const changes = {
|
|
306
399
|
content: 'Hello, world!',
|
|
@@ -439,6 +532,23 @@ describe( 'crdt', () => {
|
|
|
439
532
|
expect( metaMap?.get( 'custom_field' ) ).toBe( 'value' );
|
|
440
533
|
} );
|
|
441
534
|
|
|
535
|
+
it( 'skips function-valued content in changes', () => {
|
|
536
|
+
const changes = {
|
|
537
|
+
content: ( {
|
|
538
|
+
blocks: blocksForSerialization = [],
|
|
539
|
+
}: {
|
|
540
|
+
blocks: Block[];
|
|
541
|
+
} ) =>
|
|
542
|
+
blocksForSerialization
|
|
543
|
+
.map( ( b ) => b.attributes.content )
|
|
544
|
+
.join( '' ),
|
|
545
|
+
} as unknown as PostChanges;
|
|
546
|
+
|
|
547
|
+
applyPostChangesToCRDTDoc( doc, changes, defaultSyncedProperties );
|
|
548
|
+
|
|
549
|
+
expect( map.has( 'content' ) ).toBe( false );
|
|
550
|
+
} );
|
|
551
|
+
|
|
442
552
|
it( 'syncs taxonomy rest_base values included in syncedProperties', () => {
|
|
443
553
|
const changes = {
|
|
444
554
|
categories: [ 1, 2, 3 ],
|
|
@@ -958,6 +1068,102 @@ describe( 'crdt', () => {
|
|
|
958
1068
|
expect( changes.selection ).toBeUndefined();
|
|
959
1069
|
} );
|
|
960
1070
|
} );
|
|
1071
|
+
|
|
1072
|
+
it( 'injects a closure-based content function when blocks changed but content did not', () => {
|
|
1073
|
+
addBlockToDoc( map, 'block-1', 'Hello world' );
|
|
1074
|
+
|
|
1075
|
+
const editedRecord = {
|
|
1076
|
+
title: 'CRDT Title',
|
|
1077
|
+
status: 'draft',
|
|
1078
|
+
content: { raw: 'Same content', rendered: 'Same content' },
|
|
1079
|
+
blocks: [],
|
|
1080
|
+
} as unknown as Post;
|
|
1081
|
+
|
|
1082
|
+
const changes = getPostChangesFromCRDTDoc(
|
|
1083
|
+
doc,
|
|
1084
|
+
editedRecord,
|
|
1085
|
+
defaultSyncedProperties
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
// Blocks changed, content didn't, so a lazy content function is injected.
|
|
1089
|
+
expect( changes.blocks ).toBeDefined();
|
|
1090
|
+
expect( typeof changes.content ).toBe( 'function' );
|
|
1091
|
+
} );
|
|
1092
|
+
|
|
1093
|
+
it( 'injected content function captures the synced blocks and ignores its caller-supplied argument', () => {
|
|
1094
|
+
addBlockToDoc( map, 'block-1', 'Hello world' );
|
|
1095
|
+
|
|
1096
|
+
const editedRecord = {
|
|
1097
|
+
title: 'CRDT Title',
|
|
1098
|
+
status: 'draft',
|
|
1099
|
+
content: { raw: 'Same content', rendered: 'Same content' },
|
|
1100
|
+
blocks: [],
|
|
1101
|
+
} as unknown as Post;
|
|
1102
|
+
|
|
1103
|
+
const changes = getPostChangesFromCRDTDoc(
|
|
1104
|
+
doc,
|
|
1105
|
+
editedRecord,
|
|
1106
|
+
defaultSyncedProperties
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
// The injected function takes no parameters and serializes the
|
|
1110
|
+
// captured (synced) blocks. This is what makes getEditedPostContent
|
|
1111
|
+
// keep working after the Code Editor clears `record.blocks` to force
|
|
1112
|
+
// a re-parse: the closure already has the right blocks on hand.
|
|
1113
|
+
//
|
|
1114
|
+
// The mocked __unstableSerializeAndClean returns "serialized:<n>"
|
|
1115
|
+
// where n is the length of the blocks it was called with. The
|
|
1116
|
+
// captured blocks have one entry, so both calls below should yield
|
|
1117
|
+
// "serialized:1" (proving the closure ignores its argument and
|
|
1118
|
+
// uses the captured blocks instead).
|
|
1119
|
+
const contentFn = changes.content as ( args?: {
|
|
1120
|
+
blocks: Block[];
|
|
1121
|
+
} ) => string;
|
|
1122
|
+
expect( contentFn() ).toBe( 'serialized:1' );
|
|
1123
|
+
expect( contentFn( { blocks: [] } ) ).toBe( 'serialized:1' );
|
|
1124
|
+
} );
|
|
1125
|
+
|
|
1126
|
+
it( 'does not inject a content function when content also changed in the doc', () => {
|
|
1127
|
+
addBlockToDoc( map, 'block-1', 'Hello world' );
|
|
1128
|
+
map.set( 'content', new Y.Text( 'New content' ) );
|
|
1129
|
+
|
|
1130
|
+
const editedRecord = {
|
|
1131
|
+
title: 'CRDT Title',
|
|
1132
|
+
status: 'draft',
|
|
1133
|
+
content: { raw: 'Old content', rendered: 'Old content' },
|
|
1134
|
+
blocks: [],
|
|
1135
|
+
} as unknown as Post;
|
|
1136
|
+
|
|
1137
|
+
const changes = getPostChangesFromCRDTDoc(
|
|
1138
|
+
doc,
|
|
1139
|
+
editedRecord,
|
|
1140
|
+
defaultSyncedProperties
|
|
1141
|
+
);
|
|
1142
|
+
|
|
1143
|
+
// Content changed directly, so it should be a string, not a function.
|
|
1144
|
+
expect( changes.blocks ).toBeDefined();
|
|
1145
|
+
expect( typeof changes.content ).toBe( 'string' );
|
|
1146
|
+
expect( changes.content ).toBe( 'New content' );
|
|
1147
|
+
} );
|
|
1148
|
+
|
|
1149
|
+
it( 'does not inject a content function when blocks did not change', () => {
|
|
1150
|
+
map.set( 'content', new Y.Text( 'Same content' ) );
|
|
1151
|
+
|
|
1152
|
+
const editedRecord = {
|
|
1153
|
+
title: 'CRDT Title',
|
|
1154
|
+
status: 'draft',
|
|
1155
|
+
content: { raw: 'Same content', rendered: 'Same content' },
|
|
1156
|
+
} as unknown as Post;
|
|
1157
|
+
|
|
1158
|
+
const changes = getPostChangesFromCRDTDoc(
|
|
1159
|
+
doc,
|
|
1160
|
+
editedRecord,
|
|
1161
|
+
defaultSyncedProperties
|
|
1162
|
+
);
|
|
1163
|
+
|
|
1164
|
+
expect( changes.blocks ).toBeUndefined();
|
|
1165
|
+
expect( changes.content ).toBeUndefined();
|
|
1166
|
+
} );
|
|
961
1167
|
} );
|
|
962
1168
|
} );
|
|
963
1169
|
|