@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.
- package/CHANGELOG.md +2 -0
- package/build/actions.cjs +17 -6
- package/build/actions.cjs.map +2 -2
- package/build/awareness/base-awareness.cjs +62 -0
- package/build/awareness/base-awareness.cjs.map +7 -0
- package/build/awareness/config.cjs +34 -0
- package/build/awareness/config.cjs.map +7 -0
- package/build/awareness/post-editor-awareness.cjs +130 -0
- package/build/awareness/post-editor-awareness.cjs.map +7 -0
- package/build/awareness/types.cjs +19 -0
- package/build/awareness/types.cjs.map +7 -0
- package/build/awareness/utils.cjs +116 -0
- package/build/awareness/utils.cjs.map +7 -0
- package/build/entities.cjs +27 -2
- package/build/entities.cjs.map +2 -2
- package/build/resolvers.cjs +43 -2
- package/build/resolvers.cjs.map +2 -2
- package/build/types.cjs.map +1 -1
- package/build/utils/block-selection-history.cjs +101 -0
- package/build/utils/block-selection-history.cjs.map +7 -0
- package/build/utils/crdt-selection.cjs +139 -0
- package/build/utils/crdt-selection.cjs.map +7 -0
- package/build/utils/crdt-user-selections.cjs +171 -0
- package/build/utils/crdt-user-selections.cjs.map +7 -0
- package/build/utils/crdt-utils.cjs +29 -0
- package/build/utils/crdt-utils.cjs.map +3 -3
- package/build/utils/crdt.cjs +16 -4
- package/build/utils/crdt.cjs.map +2 -2
- package/build-module/actions.mjs +17 -6
- package/build-module/actions.mjs.map +2 -2
- package/build-module/awareness/base-awareness.mjs +35 -0
- package/build-module/awareness/base-awareness.mjs.map +7 -0
- package/build-module/awareness/config.mjs +8 -0
- package/build-module/awareness/config.mjs.map +7 -0
- package/build-module/awareness/post-editor-awareness.mjs +111 -0
- package/build-module/awareness/post-editor-awareness.mjs.map +7 -0
- package/build-module/awareness/types.mjs +1 -0
- package/build-module/awareness/types.mjs.map +7 -0
- package/build-module/awareness/utils.mjs +90 -0
- package/build-module/awareness/utils.mjs.map +7 -0
- package/build-module/entities.mjs +28 -2
- package/build-module/entities.mjs.map +2 -2
- package/build-module/resolvers.mjs +43 -2
- package/build-module/resolvers.mjs.map +2 -2
- package/build-module/utils/block-selection-history.mjs +75 -0
- package/build-module/utils/block-selection-history.mjs.map +7 -0
- package/build-module/utils/crdt-selection.mjs +115 -0
- package/build-module/utils/crdt-selection.mjs.map +7 -0
- package/build-module/utils/crdt-user-selections.mjs +144 -0
- package/build-module/utils/crdt-user-selections.mjs.map +7 -0
- package/build-module/utils/crdt-utils.mjs +28 -0
- package/build-module/utils/crdt-utils.mjs.map +2 -2
- package/build-module/utils/crdt.mjs +18 -3
- package/build-module/utils/crdt.mjs.map +2 -2
- package/build-types/actions.d.ts.map +1 -1
- package/build-types/awareness/base-awareness.d.ts +19 -0
- package/build-types/awareness/base-awareness.d.ts.map +1 -0
- package/build-types/awareness/config.d.ts +9 -0
- package/build-types/awareness/config.d.ts.map +1 -0
- package/build-types/awareness/post-editor-awareness.d.ts +38 -0
- package/build-types/awareness/post-editor-awareness.d.ts.map +1 -0
- package/build-types/awareness/types.d.ts +32 -0
- package/build-types/awareness/types.d.ts.map +1 -0
- package/build-types/awareness/utils.d.ts +22 -0
- package/build-types/awareness/utils.d.ts.map +1 -0
- package/build-types/entities.d.ts.map +1 -1
- package/build-types/entity-types/test/attachment.test.d.ts +10 -0
- package/build-types/entity-types/test/attachment.test.d.ts.map +1 -0
- package/build-types/index.d.ts.map +1 -1
- package/build-types/resolvers.d.ts.map +1 -1
- package/build-types/types.d.ts +12 -2
- package/build-types/types.d.ts.map +1 -1
- package/build-types/utils/block-selection-history.d.ts +47 -0
- package/build-types/utils/block-selection-history.d.ts.map +1 -0
- package/build-types/utils/crdt-selection.d.ts +16 -0
- package/build-types/utils/crdt-selection.d.ts.map +1 -0
- package/build-types/utils/crdt-user-selections.d.ts +66 -0
- package/build-types/utils/crdt-user-selections.d.ts.map +1 -0
- package/build-types/utils/crdt-utils.d.ts +12 -0
- package/build-types/utils/crdt-utils.d.ts.map +1 -1
- package/build-types/utils/crdt.d.ts +8 -14
- package/build-types/utils/crdt.d.ts.map +1 -1
- package/build-types/utils/test/block-selection-history.test.d.ts +2 -0
- package/build-types/utils/test/block-selection-history.test.d.ts.map +1 -0
- package/build-types/utils/test/crdt-blocks.d.ts +2 -0
- package/build-types/utils/test/crdt-blocks.d.ts.map +1 -0
- package/build-types/utils/test/crdt.d.ts +2 -0
- package/build-types/utils/test/crdt.d.ts.map +1 -0
- package/package.json +21 -18
- package/src/actions.js +40 -7
- package/src/awareness/base-awareness.ts +50 -0
- package/src/awareness/config.ts +9 -0
- package/src/awareness/post-editor-awareness.ts +167 -0
- package/src/awareness/types.ts +38 -0
- package/src/awareness/utils.ts +159 -0
- package/src/entities.js +32 -2
- package/src/entity-types/test/attachment.test.ts +4 -4
- package/src/resolvers.js +53 -1
- package/src/test/actions.js +402 -0
- package/src/test/entity-provider.js +2 -0
- package/src/test/resolvers.js +4 -0
- package/src/types.ts +12 -3
- package/src/utils/block-selection-history.ts +176 -0
- package/src/utils/crdt-selection.ts +205 -0
- package/src/utils/crdt-user-selections.ts +336 -0
- package/src/utils/crdt-utils.ts +54 -0
- package/src/utils/crdt.ts +36 -3
- package/src/utils/test/block-selection-history.test.ts +764 -0
- 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
|
+
}
|
package/src/utils/crdt-utils.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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`).
|