@wordpress/core-data 7.38.0 → 7.39.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 (109) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/build/actions.cjs +17 -6
  3. package/build/actions.cjs.map +2 -2
  4. package/build/awareness/base-awareness.cjs +62 -0
  5. package/build/awareness/base-awareness.cjs.map +7 -0
  6. package/build/awareness/config.cjs +34 -0
  7. package/build/awareness/config.cjs.map +7 -0
  8. package/build/awareness/post-editor-awareness.cjs +130 -0
  9. package/build/awareness/post-editor-awareness.cjs.map +7 -0
  10. package/build/awareness/types.cjs +19 -0
  11. package/build/awareness/types.cjs.map +7 -0
  12. package/build/awareness/utils.cjs +116 -0
  13. package/build/awareness/utils.cjs.map +7 -0
  14. package/build/entities.cjs +27 -2
  15. package/build/entities.cjs.map +2 -2
  16. package/build/resolvers.cjs +43 -2
  17. package/build/resolvers.cjs.map +2 -2
  18. package/build/types.cjs.map +1 -1
  19. package/build/utils/block-selection-history.cjs +101 -0
  20. package/build/utils/block-selection-history.cjs.map +7 -0
  21. package/build/utils/crdt-selection.cjs +139 -0
  22. package/build/utils/crdt-selection.cjs.map +7 -0
  23. package/build/utils/crdt-user-selections.cjs +171 -0
  24. package/build/utils/crdt-user-selections.cjs.map +7 -0
  25. package/build/utils/crdt-utils.cjs +29 -0
  26. package/build/utils/crdt-utils.cjs.map +3 -3
  27. package/build/utils/crdt.cjs +16 -4
  28. package/build/utils/crdt.cjs.map +2 -2
  29. package/build-module/actions.mjs +17 -6
  30. package/build-module/actions.mjs.map +2 -2
  31. package/build-module/awareness/base-awareness.mjs +35 -0
  32. package/build-module/awareness/base-awareness.mjs.map +7 -0
  33. package/build-module/awareness/config.mjs +8 -0
  34. package/build-module/awareness/config.mjs.map +7 -0
  35. package/build-module/awareness/post-editor-awareness.mjs +111 -0
  36. package/build-module/awareness/post-editor-awareness.mjs.map +7 -0
  37. package/build-module/awareness/types.mjs +1 -0
  38. package/build-module/awareness/types.mjs.map +7 -0
  39. package/build-module/awareness/utils.mjs +90 -0
  40. package/build-module/awareness/utils.mjs.map +7 -0
  41. package/build-module/entities.mjs +28 -2
  42. package/build-module/entities.mjs.map +2 -2
  43. package/build-module/resolvers.mjs +43 -2
  44. package/build-module/resolvers.mjs.map +2 -2
  45. package/build-module/utils/block-selection-history.mjs +75 -0
  46. package/build-module/utils/block-selection-history.mjs.map +7 -0
  47. package/build-module/utils/crdt-selection.mjs +115 -0
  48. package/build-module/utils/crdt-selection.mjs.map +7 -0
  49. package/build-module/utils/crdt-user-selections.mjs +144 -0
  50. package/build-module/utils/crdt-user-selections.mjs.map +7 -0
  51. package/build-module/utils/crdt-utils.mjs +28 -0
  52. package/build-module/utils/crdt-utils.mjs.map +2 -2
  53. package/build-module/utils/crdt.mjs +18 -3
  54. package/build-module/utils/crdt.mjs.map +2 -2
  55. package/build-types/actions.d.ts.map +1 -1
  56. package/build-types/awareness/base-awareness.d.ts +19 -0
  57. package/build-types/awareness/base-awareness.d.ts.map +1 -0
  58. package/build-types/awareness/config.d.ts +9 -0
  59. package/build-types/awareness/config.d.ts.map +1 -0
  60. package/build-types/awareness/post-editor-awareness.d.ts +38 -0
  61. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -0
  62. package/build-types/awareness/types.d.ts +32 -0
  63. package/build-types/awareness/types.d.ts.map +1 -0
  64. package/build-types/awareness/utils.d.ts +22 -0
  65. package/build-types/awareness/utils.d.ts.map +1 -0
  66. package/build-types/entities.d.ts.map +1 -1
  67. package/build-types/entity-types/test/attachment.test.d.ts +10 -0
  68. package/build-types/entity-types/test/attachment.test.d.ts.map +1 -0
  69. package/build-types/index.d.ts.map +1 -1
  70. package/build-types/resolvers.d.ts.map +1 -1
  71. package/build-types/types.d.ts +12 -2
  72. package/build-types/types.d.ts.map +1 -1
  73. package/build-types/utils/block-selection-history.d.ts +47 -0
  74. package/build-types/utils/block-selection-history.d.ts.map +1 -0
  75. package/build-types/utils/crdt-selection.d.ts +16 -0
  76. package/build-types/utils/crdt-selection.d.ts.map +1 -0
  77. package/build-types/utils/crdt-user-selections.d.ts +66 -0
  78. package/build-types/utils/crdt-user-selections.d.ts.map +1 -0
  79. package/build-types/utils/crdt-utils.d.ts +12 -0
  80. package/build-types/utils/crdt-utils.d.ts.map +1 -1
  81. package/build-types/utils/crdt.d.ts +8 -14
  82. package/build-types/utils/crdt.d.ts.map +1 -1
  83. package/build-types/utils/test/block-selection-history.test.d.ts +2 -0
  84. package/build-types/utils/test/block-selection-history.test.d.ts.map +1 -0
  85. package/build-types/utils/test/crdt-blocks.d.ts +2 -0
  86. package/build-types/utils/test/crdt-blocks.d.ts.map +1 -0
  87. package/build-types/utils/test/crdt.d.ts +2 -0
  88. package/build-types/utils/test/crdt.d.ts.map +1 -0
  89. package/package.json +21 -18
  90. package/src/actions.js +40 -7
  91. package/src/awareness/base-awareness.ts +50 -0
  92. package/src/awareness/config.ts +9 -0
  93. package/src/awareness/post-editor-awareness.ts +167 -0
  94. package/src/awareness/types.ts +38 -0
  95. package/src/awareness/utils.ts +159 -0
  96. package/src/entities.js +32 -2
  97. package/src/entity-types/test/attachment.test.ts +4 -4
  98. package/src/resolvers.js +53 -1
  99. package/src/test/actions.js +402 -0
  100. package/src/test/entity-provider.js +2 -0
  101. package/src/test/resolvers.js +4 -0
  102. package/src/types.ts +12 -3
  103. package/src/utils/block-selection-history.ts +176 -0
  104. package/src/utils/crdt-selection.ts +205 -0
  105. package/src/utils/crdt-user-selections.ts +336 -0
  106. package/src/utils/crdt-utils.ts +54 -0
  107. package/src/utils/crdt.ts +36 -3
  108. package/src/utils/test/block-selection-history.test.ts +764 -0
  109. package/src/utils/test/crdt-blocks.ts +11 -4
@@ -0,0 +1,205 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { dispatch, select } from '@wordpress/data';
5
+ // @ts-expect-error No exported types.
6
+ import { store as blockEditorStore } from '@wordpress/block-editor';
7
+ // @ts-expect-error No exported types.
8
+ import { isUnmodifiedBlock } from '@wordpress/blocks';
9
+ import { type CRDTDoc, Y } from '@wordpress/sync';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import {
15
+ createBlockSelectionHistory,
16
+ YSelectionType,
17
+ type BlockSelectionHistory,
18
+ type YFullSelection,
19
+ type YSelection,
20
+ } from './block-selection-history';
21
+ import { findBlockByClientIdInDoc } from './crdt-utils';
22
+ import type { WPBlockSelection, WPSelection } from '../types';
23
+
24
+ // WeakMap to store BlockSelectionHistory instances per Y.Doc
25
+ const selectionHistoryMap = new WeakMap< CRDTDoc, BlockSelectionHistory >();
26
+
27
+ /**
28
+ * Get or create a BlockSelectionHistory instance for a given Y.Doc.
29
+ *
30
+ * @param ydoc The Y.Doc to get the selection history for
31
+ * @return The BlockSelectionHistory instance
32
+ */
33
+ function getBlockSelectionHistory( ydoc: CRDTDoc ): BlockSelectionHistory {
34
+ let history = selectionHistoryMap.get( ydoc );
35
+
36
+ if ( ! history ) {
37
+ history = createBlockSelectionHistory( ydoc );
38
+ selectionHistoryMap.set( ydoc, history );
39
+ }
40
+
41
+ return history;
42
+ }
43
+
44
+ export function getSelectionHistory( ydoc: CRDTDoc ): YFullSelection[] {
45
+ return getBlockSelectionHistory( ydoc ).getSelectionHistory();
46
+ }
47
+
48
+ export function updateSelectionHistory(
49
+ ydoc: CRDTDoc,
50
+ wpSelection: WPSelection
51
+ ): void {
52
+ return getBlockSelectionHistory( ydoc ).updateSelection( wpSelection );
53
+ }
54
+
55
+ /**
56
+ * Convert a YSelection to a WPBlockSelection.
57
+ * @param ySelection The YSelection (relative) to convert
58
+ * @param ydoc The Y.Doc to convert the selection to a block selection for
59
+ * @return The converted WPBlockSelection, or null if the conversion fails
60
+ */
61
+ function convertYSelectionToBlockSelection(
62
+ ySelection: YSelection,
63
+ ydoc: Y.Doc
64
+ ): WPBlockSelection | null {
65
+ if ( ySelection.type === YSelectionType.RelativeSelection ) {
66
+ const { relativePosition, attributeKey, clientId } = ySelection;
67
+
68
+ const absolutePosition = Y.createAbsolutePositionFromRelativePosition(
69
+ relativePosition,
70
+ ydoc
71
+ );
72
+
73
+ if ( absolutePosition ) {
74
+ return {
75
+ clientId,
76
+ attributeKey,
77
+ offset: absolutePosition.index,
78
+ };
79
+ }
80
+ } else if ( ySelection.type === YSelectionType.BlockSelection ) {
81
+ return {
82
+ clientId: ySelection.clientId,
83
+ attributeKey: undefined,
84
+ offset: undefined,
85
+ };
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Given a Y.Doc and a selection history, find the most recent selection
93
+ * that exists in the document. Skip any selections that are not in the document.
94
+ * @param ydoc The Y.Doc to find the selection in
95
+ * @param selectionHistory The selection history to check
96
+ * @return The most recent selection that exists in the document, or null if no selection exists.
97
+ */
98
+ function findSelectionFromHistory(
99
+ ydoc: Y.Doc,
100
+ selectionHistory: YFullSelection[]
101
+ ): WPSelection | null {
102
+ // Try each position until we find one that exists in the document
103
+ for ( const positionToTry of selectionHistory ) {
104
+ const { start, end } = positionToTry;
105
+ const startBlock = findBlockByClientIdInDoc( start.clientId, ydoc );
106
+ const endBlock = findBlockByClientIdInDoc( end.clientId, ydoc );
107
+
108
+ if ( ! startBlock || ! endBlock ) {
109
+ // This block no longer exists, skip it.
110
+ continue;
111
+ }
112
+
113
+ const startBlockSelection = convertYSelectionToBlockSelection(
114
+ start,
115
+ ydoc
116
+ );
117
+ const endBlockSelection = convertYSelectionToBlockSelection(
118
+ end,
119
+ ydoc
120
+ );
121
+
122
+ if ( startBlockSelection === null || endBlockSelection === null ) {
123
+ continue;
124
+ }
125
+
126
+ return {
127
+ selectionStart: startBlockSelection,
128
+ selectionEnd: endBlockSelection,
129
+ };
130
+ }
131
+
132
+ return null;
133
+ }
134
+
135
+ /**
136
+ * Restore the selection to the most recent selection in history that is
137
+ * available in the document.
138
+ * @param selectionHistory The selection history to restore
139
+ * @param ydoc The Y.Doc where blocks are stored
140
+ */
141
+ export function restoreSelection(
142
+ selectionHistory: YFullSelection[],
143
+ ydoc: Y.Doc
144
+ ): void {
145
+ // Find the most recent selection in history that is available in
146
+ // the document.
147
+ const selectionToRestore = findSelectionFromHistory(
148
+ ydoc,
149
+ selectionHistory
150
+ );
151
+
152
+ if ( selectionToRestore === null ) {
153
+ // Case 1: No blocks in history are available for restoration.
154
+ // Do nothing.
155
+ return;
156
+ }
157
+
158
+ const { getBlock } = select( blockEditorStore );
159
+ const { resetSelection } = dispatch( blockEditorStore );
160
+ const { selectionStart, selectionEnd } = selectionToRestore;
161
+ const isSelectionInSameBlock =
162
+ selectionStart.clientId === selectionEnd.clientId;
163
+
164
+ if ( isSelectionInSameBlock ) {
165
+ // Case 2: After content is restored, the selection is available
166
+ // within the same block
167
+
168
+ const block = getBlock( selectionStart.clientId );
169
+ const isBlockEmpty = block && isUnmodifiedBlock( block );
170
+ const isBeginningOfEmptyBlock =
171
+ 0 === selectionStart.offset &&
172
+ 0 === selectionEnd.offset &&
173
+ isBlockEmpty;
174
+
175
+ if ( isBeginningOfEmptyBlock ) {
176
+ // Case 2a: When the content in a block has been removed after an
177
+ // undo, WordPress will set the selection to the block's client ID
178
+ // with an undefined startOffset and endOffset.
179
+ //
180
+ // To match the default behavior and tests, exclude the selection
181
+ // offset when resetting to position 0.
182
+ const selectionStartWithoutOffset = {
183
+ clientId: selectionStart.clientId,
184
+ };
185
+ const selectionEndWithoutOffset = {
186
+ clientId: selectionEnd.clientId,
187
+ };
188
+
189
+ resetSelection(
190
+ selectionStartWithoutOffset,
191
+ selectionEndWithoutOffset,
192
+ 0
193
+ );
194
+ } else {
195
+ // Case 2b: Otherwise, reset including the saved selection offset.
196
+ resetSelection( selectionStart, selectionEnd, 0 );
197
+ }
198
+ } else {
199
+ // Case 3: A multi-block selection was made. resetSelection() can only
200
+ // restore selections within the same block.
201
+ // When a multi-block selection is made, selectionEnd represents
202
+ // where the user's cursor ended.
203
+ resetSelection( selectionEnd, selectionEnd, 0 );
204
+ }
205
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import { Y, CRDT_RECORD_MAP_KEY } from '@wordpress/sync';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import type { YPostRecord } from './crdt';
10
+ import type { YBlock, YBlocks } from './crdt-blocks';
11
+ import { getRootMap } from './crdt-utils';
12
+ import type { WPBlockSelection } from '../types';
13
+
14
+ /**
15
+ * The type of selection.
16
+ */
17
+ export enum SelectionType {
18
+ None = 'none',
19
+ Cursor = 'cursor',
20
+ SelectionInOneBlock = 'selection-in-one-block',
21
+ SelectionInMultipleBlocks = 'selection-in-multiple-blocks',
22
+ WholeBlock = 'whole-block',
23
+ }
24
+
25
+ /**
26
+ * The position of the cursor.
27
+ */
28
+ export type CursorPosition = {
29
+ relativePosition: Y.RelativePosition;
30
+
31
+ // Also store the absolute offset index of the cursor from the perspective
32
+ // of the user who is updating the selection.
33
+ //
34
+ // Do not use this value directly, instead use `createAbsolutePositionFromRelativePosition()`
35
+ // on relativePosition for the most up-to-date positioning.
36
+ //
37
+ // This is used because local Y.Text changes (e.g. adding or deleting a character)
38
+ // can result in the same relative position if it is pinned to an unchanged
39
+ // character. With both of these values as editor state, a change in perceived
40
+ // position will always result in a redraw.
41
+ absoluteOffset: number;
42
+ };
43
+
44
+ export type SelectionNone = {
45
+ // The user has not made a selection.
46
+ type: SelectionType.None;
47
+ };
48
+
49
+ export type SelectionCursor = {
50
+ // The user has a cursor position in a block with no text highlighted.
51
+ type: SelectionType.Cursor;
52
+ blockId: string;
53
+ cursorPosition: CursorPosition;
54
+ };
55
+
56
+ export type SelectionInOneBlock = {
57
+ // The user has highlighted text in a single block.
58
+ type: SelectionType.SelectionInOneBlock;
59
+ blockId: string;
60
+ cursorStartPosition: CursorPosition;
61
+ cursorEndPosition: CursorPosition;
62
+ };
63
+
64
+ export type SelectionInMultipleBlocks = {
65
+ // The user has highlighted text over multiple blocks.
66
+ type: SelectionType.SelectionInMultipleBlocks;
67
+ blockStartId: string;
68
+ blockEndId: string;
69
+ cursorStartPosition: CursorPosition;
70
+ cursorEndPosition: CursorPosition;
71
+ };
72
+
73
+ export type SelectionWholeBlock = {
74
+ // The user has a non-text block selected, like an image block.
75
+ type: SelectionType.WholeBlock;
76
+ blockId: string;
77
+ };
78
+
79
+ export type SelectionState =
80
+ | SelectionNone
81
+ | SelectionCursor
82
+ | SelectionInOneBlock
83
+ | SelectionInMultipleBlocks
84
+ | SelectionWholeBlock;
85
+
86
+ /**
87
+ * Converts WordPress block editor selection to a SelectionState.
88
+ *
89
+ * @param selectionStart - The start position of the selection
90
+ * @param selectionEnd - The end position of the selection
91
+ * @param yDoc - The Yjs document
92
+ * @return The SelectionState
93
+ */
94
+ export function getSelectionState(
95
+ selectionStart: WPBlockSelection,
96
+ selectionEnd: WPBlockSelection,
97
+ yDoc: Y.Doc
98
+ ): SelectionState {
99
+ const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY );
100
+ const yBlocks = ymap.get( 'blocks' ) ?? new Y.Array< YBlock >();
101
+
102
+ const isSelectionEmpty = Object.keys( selectionStart ).length === 0;
103
+ const noSelection: SelectionNone = {
104
+ type: SelectionType.None,
105
+ };
106
+
107
+ if ( isSelectionEmpty ) {
108
+ // Case 1: No selection
109
+ return noSelection;
110
+ }
111
+
112
+ // When the page initially loads, selectionStart can contain an empty object `{}`.
113
+ const isSelectionInOneBlock =
114
+ selectionStart.clientId === selectionEnd.clientId;
115
+ const isCursorOnly =
116
+ isSelectionInOneBlock && selectionStart.offset === selectionEnd.offset;
117
+ const isSelectionAWholeBlock =
118
+ isSelectionInOneBlock &&
119
+ selectionStart.offset === undefined &&
120
+ selectionEnd.offset === undefined;
121
+
122
+ if ( isSelectionAWholeBlock ) {
123
+ // Case 2: A whole block is selected.
124
+ return {
125
+ type: SelectionType.WholeBlock,
126
+ blockId: selectionStart.clientId,
127
+ };
128
+ } else if ( isCursorOnly ) {
129
+ // Case 3: Cursor only, no text selected
130
+ const cursorPosition = getCursorPosition( selectionStart, yBlocks );
131
+
132
+ if ( ! cursorPosition ) {
133
+ // If we can't find the cursor position in block text, treat it as a non-selection.
134
+ return noSelection;
135
+ }
136
+
137
+ return {
138
+ type: SelectionType.Cursor,
139
+ blockId: selectionStart.clientId,
140
+ cursorPosition,
141
+ };
142
+ } else if ( isSelectionInOneBlock ) {
143
+ // Case 4: Selection in a single block
144
+ const cursorStartPosition = getCursorPosition(
145
+ selectionStart,
146
+ yBlocks
147
+ );
148
+ const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks );
149
+
150
+ if ( ! cursorStartPosition || ! cursorEndPosition ) {
151
+ // If we can't find the cursor positions in block text, treat it as a non-selection.
152
+ return noSelection;
153
+ }
154
+
155
+ return {
156
+ type: SelectionType.SelectionInOneBlock,
157
+ blockId: selectionStart.clientId,
158
+ cursorStartPosition,
159
+ cursorEndPosition,
160
+ };
161
+ }
162
+
163
+ // Caes 5: Selection in multiple blocks
164
+ const cursorStartPosition = getCursorPosition( selectionStart, yBlocks );
165
+ const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks );
166
+ if ( ! cursorStartPosition || ! cursorEndPosition ) {
167
+ // If we can't find the cursor positions in block text, treat it as a non-selection.
168
+ return noSelection;
169
+ }
170
+
171
+ return {
172
+ type: SelectionType.SelectionInMultipleBlocks,
173
+ blockStartId: selectionStart.clientId,
174
+ blockEndId: selectionEnd.clientId,
175
+ cursorStartPosition,
176
+ cursorEndPosition,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Get the cursor position from a selection.
182
+ *
183
+ * @param selection - The selection.
184
+ * @param blocks - The blocks to search through.
185
+ * @return The cursor position, or null if not found.
186
+ */
187
+ function getCursorPosition(
188
+ selection: WPBlockSelection,
189
+ blocks: YBlocks
190
+ ): CursorPosition | null {
191
+ const block = findBlockByClientId( selection.clientId, blocks );
192
+ if (
193
+ ! block ||
194
+ ! selection.attributeKey ||
195
+ undefined === selection.offset
196
+ ) {
197
+ return null;
198
+ }
199
+
200
+ const attributes = block.get( 'attributes' );
201
+ const currentYText = attributes?.get( selection.attributeKey ) as Y.Text;
202
+
203
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
204
+ currentYText,
205
+ selection.offset
206
+ );
207
+
208
+ return {
209
+ relativePosition,
210
+ absoluteOffset: selection.offset,
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Find a block by its client ID.
216
+ *
217
+ * @param blockId - The client ID of the block.
218
+ * @param blocks - The blocks to search through.
219
+ * @return The block if found, null otherwise.
220
+ */
221
+ function findBlockByClientId(
222
+ blockId: string,
223
+ blocks: YBlocks
224
+ ): YBlock | null {
225
+ for ( const block of blocks ) {
226
+ if ( block.get( 'clientId' ) === blockId ) {
227
+ return block;
228
+ }
229
+
230
+ const innerBlocks = block.get( 'innerBlocks' );
231
+
232
+ if ( innerBlocks && innerBlocks.length > 0 ) {
233
+ const innerBlock = findBlockByClientId( blockId, innerBlocks );
234
+
235
+ if ( innerBlock ) {
236
+ return innerBlock;
237
+ }
238
+ }
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Check if two selection states are equal.
246
+ *
247
+ * @param selection1 - The first selection state.
248
+ * @param selection2 - The second selection state.
249
+ * @return True if the selection states are equal, false otherwise.
250
+ */
251
+ export function areSelectionsStatesEqual(
252
+ selection1: SelectionState,
253
+ selection2: SelectionState
254
+ ): boolean {
255
+ if ( selection1.type !== selection2.type ) {
256
+ return false;
257
+ }
258
+
259
+ switch ( selection1.type ) {
260
+ case SelectionType.None:
261
+ return true;
262
+
263
+ case SelectionType.Cursor:
264
+ return (
265
+ selection1.blockId ===
266
+ ( selection2 as SelectionCursor ).blockId &&
267
+ areCursorPositionsEqual(
268
+ selection1.cursorPosition,
269
+ ( selection2 as SelectionCursor ).cursorPosition
270
+ )
271
+ );
272
+
273
+ case SelectionType.SelectionInOneBlock:
274
+ return (
275
+ selection1.blockId ===
276
+ ( selection2 as SelectionInOneBlock ).blockId &&
277
+ areCursorPositionsEqual(
278
+ selection1.cursorStartPosition,
279
+ ( selection2 as SelectionInOneBlock ).cursorStartPosition
280
+ ) &&
281
+ areCursorPositionsEqual(
282
+ selection1.cursorEndPosition,
283
+ ( selection2 as SelectionInOneBlock ).cursorEndPosition
284
+ )
285
+ );
286
+
287
+ case SelectionType.SelectionInMultipleBlocks:
288
+ return (
289
+ selection1.blockStartId ===
290
+ ( selection2 as SelectionInMultipleBlocks ).blockStartId &&
291
+ selection1.blockEndId ===
292
+ ( selection2 as SelectionInMultipleBlocks ).blockEndId &&
293
+ areCursorPositionsEqual(
294
+ selection1.cursorStartPosition,
295
+ ( selection2 as SelectionInMultipleBlocks )
296
+ .cursorStartPosition
297
+ ) &&
298
+ areCursorPositionsEqual(
299
+ selection1.cursorEndPosition,
300
+ ( selection2 as SelectionInMultipleBlocks )
301
+ .cursorEndPosition
302
+ )
303
+ );
304
+ case SelectionType.WholeBlock:
305
+ return (
306
+ selection1.blockId ===
307
+ ( selection2 as SelectionWholeBlock ).blockId
308
+ );
309
+
310
+ default:
311
+ return false;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Check if two cursor positions are equal.
317
+ *
318
+ * @param cursorPosition1 - The first cursor position.
319
+ * @param cursorPosition2 - The second cursor position.
320
+ * @return True if the cursor positions are equal, false otherwise.
321
+ */
322
+ function areCursorPositionsEqual(
323
+ cursorPosition1: CursorPosition,
324
+ cursorPosition2: CursorPosition
325
+ ): boolean {
326
+ const isRelativePositionEqual =
327
+ JSON.stringify( cursorPosition1.relativePosition ) ===
328
+ JSON.stringify( cursorPosition2.relativePosition );
329
+
330
+ // Ensure a change in calculated absolute offset results in a treating the cursor as modified.
331
+ // This is necessary because Y.Text relative positions can remain the same after text changes.
332
+ const isAbsoluteOffsetEqual =
333
+ cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset;
334
+
335
+ return isRelativePositionEqual && isAbsoluteOffsetEqual;
336
+ }
@@ -3,6 +3,13 @@
3
3
  */
4
4
  import { Y } from '@wordpress/sync';
5
5
 
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import type { YBlock, YBlocks } from './crdt-blocks';
10
+ import type { YPostRecord } from './crdt';
11
+ import { CRDT_RECORD_MAP_KEY } from '../sync';
12
+
6
13
  /**
7
14
  * A YMapRecord represents the shape of the data stored in a Y.Map.
8
15
  */
@@ -75,3 +82,50 @@ export function isYMap< T extends YMapRecord >(
75
82
  ): value is YMapWrap< T > {
76
83
  return value instanceof Y.Map;
77
84
  }
85
+
86
+ /**
87
+ * Given a block ID and a Y.Doc, find the block in the document.
88
+ *
89
+ * @param blockId The block ID to find
90
+ * @param ydoc The Y.Doc to find the block in
91
+ * @return The block, or null if the block is not found
92
+ */
93
+ export function findBlockByClientIdInDoc(
94
+ blockId: string,
95
+ ydoc: Y.Doc
96
+ ): YBlock | null {
97
+ const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY );
98
+ const blocks = ymap.get( 'blocks' );
99
+
100
+ if ( ! ( blocks instanceof Y.Array ) ) {
101
+ return null;
102
+ }
103
+
104
+ return findBlockByClientIdInBlocks( blockId, blocks );
105
+ }
106
+
107
+ function findBlockByClientIdInBlocks(
108
+ blockId: string,
109
+ blocks: YBlocks
110
+ ): YBlock | null {
111
+ for ( const block of blocks ) {
112
+ if ( block.get( 'clientId' ) === blockId ) {
113
+ return block;
114
+ }
115
+
116
+ const innerBlocks = block.get( 'innerBlocks' );
117
+
118
+ if ( innerBlocks && innerBlocks.length > 0 ) {
119
+ const innerBlock = findBlockByClientIdInBlocks(
120
+ blockId,
121
+ innerBlocks
122
+ );
123
+
124
+ if ( innerBlock ) {
125
+ return innerBlock;
126
+ }
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
package/src/utils/crdt.ts CHANGED
@@ -8,11 +8,18 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
8
8
  */
9
9
  // @ts-expect-error No exported types.
10
10
  import { __unstableSerializeAndClean } from '@wordpress/blocks';
11
- import { type CRDTDoc, type ObjectData, Y } from '@wordpress/sync';
11
+ import {
12
+ type CRDTDoc,
13
+ type ObjectData,
14
+ type SyncConfig,
15
+ Y,
16
+ } from '@wordpress/sync';
12
17
 
13
18
  /**
14
19
  * Internal dependencies
15
20
  */
21
+ import { BaseAwareness } from '../awareness/base-awareness';
22
+ import { type BaseState } from '../awareness/types';
16
23
  import {
17
24
  mergeCrdtBlocks,
18
25
  type Block,
@@ -27,6 +34,7 @@ import {
27
34
  WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
28
35
  } from '../sync';
29
36
  import type { WPSelection } from '../types';
37
+ import { updateSelectionHistory } from './crdt-selection';
30
38
  import {
31
39
  createYMap,
32
40
  getRootMap,
@@ -47,6 +55,7 @@ export type PostChanges = Partial< Post > & {
47
55
  export interface YPostRecord extends YMapRecord {
48
56
  author: number;
49
57
  blocks: YBlocks;
58
+ categories: number[];
50
59
  comment_status: string;
51
60
  date: string | null;
52
61
  excerpt: string;
@@ -66,6 +75,7 @@ export interface YPostRecord extends YMapRecord {
66
75
  const allowedPostProperties = new Set< string >( [
67
76
  'author',
68
77
  'blocks',
78
+ 'categories',
69
79
  'comment_status',
70
80
  'date',
71
81
  'excerpt',
@@ -94,7 +104,7 @@ const disallowedPostMetaKeys = new Set< string >( [
94
104
  * @param {Partial< ObjectData >} changes
95
105
  * @return {void}
96
106
  */
97
- export function defaultApplyChangesToCRDTDoc(
107
+ function defaultApplyChangesToCRDTDoc(
98
108
  ydoc: CRDTDoc,
99
109
  changes: ObjectData
100
110
  ): void {
@@ -240,9 +250,22 @@ export function applyPostChangesToCRDTDoc(
240
250
  }
241
251
  }
242
252
  } );
253
+
254
+ // Process changes that we don't want to persist to the CRDT document.
255
+ if ( changes.selection ) {
256
+ const selection = changes.selection;
257
+ // Persist selection changes at the end of the current event loop.
258
+ // This allows undo meta to be saved with the current selection before
259
+ // it is overwritten by the new selection from Gutenberg.
260
+ // Without this, selection history will already contain the latest
261
+ // selection (after this change) when the undo stack is saved.
262
+ setTimeout( () => {
263
+ updateSelectionHistory( ydoc, selection );
264
+ }, 0 );
265
+ }
243
266
  }
244
267
 
245
- export function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
268
+ function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
246
269
  return getRootMap( crdtDoc, CRDT_RECORD_MAP_KEY ).toJSON();
247
270
  }
248
271
 
@@ -382,6 +405,16 @@ export function getPostChangesFromCRDTDoc(
382
405
  return changes;
383
406
  }
384
407
 
408
+ /**
409
+ * This default sync config can be used for entities that are flat maps of
410
+ * primitive values and do not require custom logic to merge changes.
411
+ */
412
+ export const defaultSyncConfig: SyncConfig< BaseState > = {
413
+ applyChangesToCRDTDoc: defaultApplyChangesToCRDTDoc,
414
+ createAwareness: ( ydoc: CRDTDoc ) => new BaseAwareness( ydoc ),
415
+ getChangesFromCRDTDoc: defaultGetChangesFromCRDTDoc,
416
+ };
417
+
385
418
  /**
386
419
  * Extract the raw string value from a property that may be a string or an object
387
420
  * with a `raw` property (`RenderedText`).