@wordpress/core-data 7.38.0 → 7.38.1-next.v.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/build/awareness/config.cjs +34 -0
- package/build/awareness/config.cjs.map +7 -0
- package/build/awareness/post-editor-awareness.cjs +144 -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 +13 -0
- package/build/entities.cjs.map +2 -2
- package/build/resolvers.cjs +3 -2
- package/build/resolvers.cjs.map +2 -2
- package/build/utils/crdt-user-selections.cjs +171 -0
- package/build/utils/crdt-user-selections.cjs.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 +125 -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 +13 -0
- package/build-module/entities.mjs.map +2 -2
- package/build-module/resolvers.mjs +3 -2
- package/build-module/resolvers.mjs.map +2 -2
- package/build-module/utils/crdt-user-selections.mjs +144 -0
- package/build-module/utils/crdt-user-selections.mjs.map +7 -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 +39 -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/resolvers.d.ts.map +1 -1
- package/build-types/utils/crdt-user-selections.d.ts +66 -0
- package/build-types/utils/crdt-user-selections.d.ts.map +1 -0
- package/package.json +19 -18
- package/src/awareness/config.ts +9 -0
- package/src/awareness/post-editor-awareness.ts +187 -0
- package/src/awareness/types.ts +38 -0
- package/src/awareness/utils.ts +159 -0
- package/src/entities.js +14 -0
- package/src/resolvers.js +2 -1
- package/src/test/entity-provider.js +2 -0
- package/src/utils/crdt-user-selections.ts +332 -0
- package/src/utils/test/crdt-blocks.ts +11 -4
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal dependencies
|
|
3
|
+
*/
|
|
4
|
+
import type { SelectionState } from '../utils/crdt-user-selections';
|
|
5
|
+
import type { User } from '../entity-types';
|
|
6
|
+
|
|
7
|
+
export type UserInfo = Pick<
|
|
8
|
+
User< 'view' >,
|
|
9
|
+
'id' | 'name' | 'slug' | 'avatar_urls'
|
|
10
|
+
> & {
|
|
11
|
+
browserType: string;
|
|
12
|
+
color: string;
|
|
13
|
+
enteredAt: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* This base state represents the presence of the user. We expect it to be
|
|
18
|
+
* extended to include additional state describing the user's current activity.
|
|
19
|
+
* This state must be serializable and compact.
|
|
20
|
+
*/
|
|
21
|
+
export interface BaseState {
|
|
22
|
+
userInfo: UserInfo;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The editor state includes information about the user's current selection.
|
|
27
|
+
*/
|
|
28
|
+
export interface EditorState {
|
|
29
|
+
selection: SelectionState;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The post editor state extends the base state with information used to render
|
|
34
|
+
* presence indicators in the post editor.
|
|
35
|
+
*/
|
|
36
|
+
export interface PostEditorState extends BaseState {
|
|
37
|
+
editorState?: EditorState;
|
|
38
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal dependencies
|
|
3
|
+
*/
|
|
4
|
+
import type { User } from '../entity-types';
|
|
5
|
+
import type { UserInfo } from './types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The color palette for the user highlight.
|
|
9
|
+
*/
|
|
10
|
+
const COLOR_PALETTE = [
|
|
11
|
+
'#3858E9', // blueberry
|
|
12
|
+
'#B42AED', // purple
|
|
13
|
+
'#E33184', // pink
|
|
14
|
+
'#F3661D', // orange
|
|
15
|
+
'#ECBD3A', // yellow
|
|
16
|
+
'#97FE17', // green
|
|
17
|
+
'#00FDD9', // teal
|
|
18
|
+
'#37C5F0', // cyan
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate a random integer between min and max, inclusive.
|
|
23
|
+
*
|
|
24
|
+
* @param min - The minimum value.
|
|
25
|
+
* @param max - The maximum value.
|
|
26
|
+
* @return A random integer between min and max.
|
|
27
|
+
*/
|
|
28
|
+
function generateRandomInt( min: number, max: number ): number {
|
|
29
|
+
return Math.floor( Math.random() * ( max - min + 1 ) ) + min;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get a unique user color from the palette, or generate a variation if none are available.
|
|
34
|
+
* If the previously used color is available from localStorage, use it.
|
|
35
|
+
*
|
|
36
|
+
* @param existingColors - Colors that are already in use.
|
|
37
|
+
* @return The new user color, in hex format.
|
|
38
|
+
*/
|
|
39
|
+
function getNewUserColor( existingColors: string[] ): string {
|
|
40
|
+
const availableColors = COLOR_PALETTE.filter(
|
|
41
|
+
( color ) => ! existingColors.includes( color )
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
let hexColor: string;
|
|
45
|
+
|
|
46
|
+
if ( availableColors.length > 0 ) {
|
|
47
|
+
const randomIndex = generateRandomInt( 0, availableColors.length - 1 );
|
|
48
|
+
hexColor = availableColors[ randomIndex ];
|
|
49
|
+
} else {
|
|
50
|
+
// All colors are used, generate a variation of a random palette color
|
|
51
|
+
const randomIndex = generateRandomInt( 0, COLOR_PALETTE.length - 1 );
|
|
52
|
+
const baseColor = COLOR_PALETTE[ randomIndex ];
|
|
53
|
+
hexColor = generateColorVariation( baseColor );
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return hexColor;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generate a variation of a hex color by adjusting its lightness.
|
|
61
|
+
*
|
|
62
|
+
* @param hexColor - The base hex color (e.g., '#3858E9').
|
|
63
|
+
* @return A varied hex color.
|
|
64
|
+
*/
|
|
65
|
+
function generateColorVariation( hexColor: string ): string {
|
|
66
|
+
// Parse hex to RGB
|
|
67
|
+
const r = parseInt( hexColor.slice( 1, 3 ), 16 );
|
|
68
|
+
const g = parseInt( hexColor.slice( 3, 5 ), 16 );
|
|
69
|
+
const b = parseInt( hexColor.slice( 5, 7 ), 16 );
|
|
70
|
+
|
|
71
|
+
// Apply a random lightness shift (-30 to +30)
|
|
72
|
+
const shift = generateRandomInt( -30, 30 );
|
|
73
|
+
const newR = Math.min( 255, Math.max( 0, r + shift ) );
|
|
74
|
+
const newG = Math.min( 255, Math.max( 0, g + shift ) );
|
|
75
|
+
const newB = Math.min( 255, Math.max( 0, b + shift ) );
|
|
76
|
+
|
|
77
|
+
// Convert back to hex
|
|
78
|
+
const toHex = ( n: number ) =>
|
|
79
|
+
n.toString( 16 ).padStart( 2, '0' ).toUpperCase();
|
|
80
|
+
return `#${ toHex( newR ) }${ toHex( newG ) }${ toHex( newB ) }`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the browser name from the user agent.
|
|
85
|
+
* @return The browser name.
|
|
86
|
+
*/
|
|
87
|
+
function getBrowserName(): string {
|
|
88
|
+
const userAgent = window.navigator.userAgent;
|
|
89
|
+
let browserName = 'Unknown';
|
|
90
|
+
|
|
91
|
+
if ( userAgent.includes( 'Firefox' ) ) {
|
|
92
|
+
browserName = 'Firefox';
|
|
93
|
+
} else if ( userAgent.includes( 'Edg' ) ) {
|
|
94
|
+
browserName = 'Microsoft Edge';
|
|
95
|
+
} else if (
|
|
96
|
+
userAgent.includes( 'Chrome' ) &&
|
|
97
|
+
! userAgent.includes( 'Edg' )
|
|
98
|
+
) {
|
|
99
|
+
browserName = 'Chrome';
|
|
100
|
+
} else if (
|
|
101
|
+
userAgent.includes( 'Safari' ) &&
|
|
102
|
+
! userAgent.includes( 'Chrome' )
|
|
103
|
+
) {
|
|
104
|
+
browserName = 'Safari';
|
|
105
|
+
} else if (
|
|
106
|
+
userAgent.includes( 'MSIE' ) ||
|
|
107
|
+
userAgent.includes( 'Trident' )
|
|
108
|
+
) {
|
|
109
|
+
browserName = 'Internet Explorer';
|
|
110
|
+
} else if ( userAgent.includes( 'Opera' ) || userAgent.includes( 'OPR' ) ) {
|
|
111
|
+
browserName = 'Opera';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return browserName;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if two user infos are equal.
|
|
119
|
+
*
|
|
120
|
+
* @param userInfo1 - The first user info.
|
|
121
|
+
* @param userInfo2 - The second user info.
|
|
122
|
+
* @return True if the user infos are equal, false otherwise.
|
|
123
|
+
*/
|
|
124
|
+
export function areUserInfosEqual(
|
|
125
|
+
userInfo1?: UserInfo,
|
|
126
|
+
userInfo2?: UserInfo
|
|
127
|
+
): boolean {
|
|
128
|
+
if ( ! userInfo1 || ! userInfo2 ) {
|
|
129
|
+
return userInfo1 === userInfo2;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if ( Object.keys( userInfo1 ).length !== Object.keys( userInfo2 ).length ) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Object.entries( userInfo1 ).every( ( [ key, value ] ) => {
|
|
137
|
+
// Update this function with any non-primitive fields added to UserInfo.
|
|
138
|
+
return value === userInfo2[ key as keyof UserInfo ];
|
|
139
|
+
} );
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Generate a user info object from a current user and a list of existing colors.
|
|
144
|
+
*
|
|
145
|
+
* @param currentUser - The current user.
|
|
146
|
+
* @param existingColors - The existing colors.
|
|
147
|
+
* @return The user info object.
|
|
148
|
+
*/
|
|
149
|
+
export function generateUserInfo(
|
|
150
|
+
currentUser: User< 'view' >,
|
|
151
|
+
existingColors: string[]
|
|
152
|
+
): UserInfo {
|
|
153
|
+
return {
|
|
154
|
+
...currentUser,
|
|
155
|
+
browserType: getBrowserName(),
|
|
156
|
+
color: getNewUserColor( existingColors ),
|
|
157
|
+
enteredAt: Date.now(),
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/entities.js
CHANGED
|
@@ -13,6 +13,7 @@ import { __ } from '@wordpress/i18n';
|
|
|
13
13
|
/**
|
|
14
14
|
* Internal dependencies
|
|
15
15
|
*/
|
|
16
|
+
import { PostEditorAwareness } from './awareness/post-editor-awareness';
|
|
16
17
|
import { getSyncManager } from './sync';
|
|
17
18
|
import {
|
|
18
19
|
applyPostChangesToCRDTDoc,
|
|
@@ -358,6 +359,19 @@ async function loadPostTypeEntities() {
|
|
|
358
359
|
applyChangesToCRDTDoc: ( crdtDoc, changes ) =>
|
|
359
360
|
applyPostChangesToCRDTDoc( crdtDoc, changes, postType ),
|
|
360
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Create the awareness instance for the entity's CRDT document.
|
|
364
|
+
*
|
|
365
|
+
* @param {import('@wordpress/sync').CRDTDoc} ydoc
|
|
366
|
+
* @param {import('@wordpress/sync').ObjectID} objectId
|
|
367
|
+
* @return {import('@wordpress/sync').AwarenessState} AwarenessState instance
|
|
368
|
+
*/
|
|
369
|
+
createAwareness: ( ydoc, objectId ) => {
|
|
370
|
+
const kind = 'postType';
|
|
371
|
+
const id = parseInt( objectId, 10 );
|
|
372
|
+
return new PostEditorAwareness( ydoc, kind, name, id );
|
|
373
|
+
},
|
|
374
|
+
|
|
361
375
|
/**
|
|
362
376
|
* Extract changes from a CRDT document that can be used to update the
|
|
363
377
|
* local editor state.
|
package/src/resolvers.js
CHANGED
|
@@ -192,7 +192,7 @@ export const getEntityRecord =
|
|
|
192
192
|
recordWithTransients,
|
|
193
193
|
{
|
|
194
194
|
// Handle edits sourced from the sync manager.
|
|
195
|
-
editRecord: ( edits ) => {
|
|
195
|
+
editRecord: ( edits, options = {} ) => {
|
|
196
196
|
if ( ! Object.keys( edits ).length ) {
|
|
197
197
|
return;
|
|
198
198
|
}
|
|
@@ -206,6 +206,7 @@ export const getEntityRecord =
|
|
|
206
206
|
meta: {
|
|
207
207
|
undo: undefined,
|
|
208
208
|
},
|
|
209
|
+
options,
|
|
209
210
|
} );
|
|
210
211
|
},
|
|
211
212
|
// Get the current entity record (with edits)
|
|
@@ -86,6 +86,7 @@ describe( 'useEntityBlockEditor', () => {
|
|
|
86
86
|
const edit = ( { children } ) => <>{ children }</>;
|
|
87
87
|
|
|
88
88
|
registerBlockType( 'core/test-block', {
|
|
89
|
+
apiVersion: 3,
|
|
89
90
|
supports: {
|
|
90
91
|
className: false,
|
|
91
92
|
},
|
|
@@ -112,6 +113,7 @@ describe( 'useEntityBlockEditor', () => {
|
|
|
112
113
|
} );
|
|
113
114
|
|
|
114
115
|
registerBlockType( 'core/test-block-with-array-of-strings', {
|
|
116
|
+
apiVersion: 3,
|
|
115
117
|
supports: {
|
|
116
118
|
className: false,
|
|
117
119
|
},
|
|
@@ -0,0 +1,332 @@
|
|
|
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 ( ! block ) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const attributes = block.get( 'attributes' ) as Y.Map< Y.Text >;
|
|
197
|
+
const currentYText = attributes.get( selection.attributeKey ) as Y.Text;
|
|
198
|
+
|
|
199
|
+
const relativePosition = Y.createRelativePositionFromTypeIndex(
|
|
200
|
+
currentYText,
|
|
201
|
+
selection.offset
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
relativePosition,
|
|
206
|
+
absoluteOffset: selection.offset,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Find a block by its client ID.
|
|
212
|
+
*
|
|
213
|
+
* @param blockId - The client ID of the block.
|
|
214
|
+
* @param blocks - The blocks to search through.
|
|
215
|
+
* @return The block if found, null otherwise.
|
|
216
|
+
*/
|
|
217
|
+
function findBlockByClientId(
|
|
218
|
+
blockId: string,
|
|
219
|
+
blocks: YBlocks
|
|
220
|
+
): YBlock | null {
|
|
221
|
+
for ( const block of blocks ) {
|
|
222
|
+
if ( block.get( 'clientId' ) === blockId ) {
|
|
223
|
+
return block;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const innerBlocks = block.get( 'innerBlocks' );
|
|
227
|
+
|
|
228
|
+
if ( innerBlocks && innerBlocks.length > 0 ) {
|
|
229
|
+
const innerBlock = findBlockByClientId( blockId, innerBlocks );
|
|
230
|
+
|
|
231
|
+
if ( innerBlock ) {
|
|
232
|
+
return innerBlock;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if two selection states are equal.
|
|
242
|
+
*
|
|
243
|
+
* @param selection1 - The first selection state.
|
|
244
|
+
* @param selection2 - The second selection state.
|
|
245
|
+
* @return True if the selection states are equal, false otherwise.
|
|
246
|
+
*/
|
|
247
|
+
export function areSelectionsStatesEqual(
|
|
248
|
+
selection1: SelectionState,
|
|
249
|
+
selection2: SelectionState
|
|
250
|
+
): boolean {
|
|
251
|
+
if ( selection1.type !== selection2.type ) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
switch ( selection1.type ) {
|
|
256
|
+
case SelectionType.None:
|
|
257
|
+
return true;
|
|
258
|
+
|
|
259
|
+
case SelectionType.Cursor:
|
|
260
|
+
return (
|
|
261
|
+
selection1.blockId ===
|
|
262
|
+
( selection2 as SelectionCursor ).blockId &&
|
|
263
|
+
areCursorPositionsEqual(
|
|
264
|
+
selection1.cursorPosition,
|
|
265
|
+
( selection2 as SelectionCursor ).cursorPosition
|
|
266
|
+
)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
case SelectionType.SelectionInOneBlock:
|
|
270
|
+
return (
|
|
271
|
+
selection1.blockId ===
|
|
272
|
+
( selection2 as SelectionInOneBlock ).blockId &&
|
|
273
|
+
areCursorPositionsEqual(
|
|
274
|
+
selection1.cursorStartPosition,
|
|
275
|
+
( selection2 as SelectionInOneBlock ).cursorStartPosition
|
|
276
|
+
) &&
|
|
277
|
+
areCursorPositionsEqual(
|
|
278
|
+
selection1.cursorEndPosition,
|
|
279
|
+
( selection2 as SelectionInOneBlock ).cursorEndPosition
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
case SelectionType.SelectionInMultipleBlocks:
|
|
284
|
+
return (
|
|
285
|
+
selection1.blockStartId ===
|
|
286
|
+
( selection2 as SelectionInMultipleBlocks ).blockStartId &&
|
|
287
|
+
selection1.blockEndId ===
|
|
288
|
+
( selection2 as SelectionInMultipleBlocks ).blockEndId &&
|
|
289
|
+
areCursorPositionsEqual(
|
|
290
|
+
selection1.cursorStartPosition,
|
|
291
|
+
( selection2 as SelectionInMultipleBlocks )
|
|
292
|
+
.cursorStartPosition
|
|
293
|
+
) &&
|
|
294
|
+
areCursorPositionsEqual(
|
|
295
|
+
selection1.cursorEndPosition,
|
|
296
|
+
( selection2 as SelectionInMultipleBlocks )
|
|
297
|
+
.cursorEndPosition
|
|
298
|
+
)
|
|
299
|
+
);
|
|
300
|
+
case SelectionType.WholeBlock:
|
|
301
|
+
return (
|
|
302
|
+
selection1.blockId ===
|
|
303
|
+
( selection2 as SelectionWholeBlock ).blockId
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
default:
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Check if two cursor positions are equal.
|
|
313
|
+
*
|
|
314
|
+
* @param cursorPosition1 - The first cursor position.
|
|
315
|
+
* @param cursorPosition2 - The second cursor position.
|
|
316
|
+
* @return True if the cursor positions are equal, false otherwise.
|
|
317
|
+
*/
|
|
318
|
+
function areCursorPositionsEqual(
|
|
319
|
+
cursorPosition1: CursorPosition,
|
|
320
|
+
cursorPosition2: CursorPosition
|
|
321
|
+
): boolean {
|
|
322
|
+
const isRelativePositionEqual =
|
|
323
|
+
JSON.stringify( cursorPosition1.relativePosition ) ===
|
|
324
|
+
JSON.stringify( cursorPosition2.relativePosition );
|
|
325
|
+
|
|
326
|
+
// Ensure a change in calculated absolute offset results in a treating the cursor as modified.
|
|
327
|
+
// This is necessary because Y.Text relative positions can remain the same after text changes.
|
|
328
|
+
const isAbsoluteOffsetEqual =
|
|
329
|
+
cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset;
|
|
330
|
+
|
|
331
|
+
return isRelativePositionEqual && isAbsoluteOffsetEqual;
|
|
332
|
+
}
|
|
@@ -6,25 +6,32 @@ import { Y } from '@wordpress/sync';
|
|
|
6
6
|
/**
|
|
7
7
|
* External dependencies
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
describe,
|
|
11
|
+
expect,
|
|
12
|
+
it,
|
|
13
|
+
jest,
|
|
14
|
+
beforeEach,
|
|
15
|
+
afterEach,
|
|
16
|
+
} from '@jest/globals';
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Mock uuid module
|
|
13
20
|
*/
|
|
14
21
|
jest.mock( 'uuid', () => ( {
|
|
15
|
-
v4:
|
|
22
|
+
v4: () => 'mocked-uuid-' + Math.random(),
|
|
16
23
|
} ) );
|
|
17
24
|
|
|
18
25
|
/**
|
|
19
26
|
* Mock @wordpress/blocks module
|
|
20
27
|
*/
|
|
21
28
|
jest.mock( '@wordpress/blocks', () => ( {
|
|
22
|
-
getBlockTypes:
|
|
29
|
+
getBlockTypes: () => [
|
|
23
30
|
{
|
|
24
31
|
name: 'core/paragraph',
|
|
25
32
|
attributes: { content: { type: 'rich-text' } },
|
|
26
33
|
},
|
|
27
|
-
]
|
|
34
|
+
],
|
|
28
35
|
} ) );
|
|
29
36
|
|
|
30
37
|
/**
|