Skip to content

Commit a246a38

Browse files
alecgeatchesalecgeatchesmaxschmelingchriszarate
authored
RTC: Add collaborator selection highlighting in rich text (#76107)
* Add full-selection highlighting * Fix backwards selection cursor placement and selection across blocks * Add selection range tests * Export ResolvedSelection from core-data for use in overlay * Refactor useRenderCursors() into three files, split on DOM and selection tasks. * Fix type export * Fix selection direction when using undo/redo * Add e2e tests for full selection awareness * Reduce opacity of selection rectangle in overlay * Pre-compute overlayRect, pre-compute DOM elements where possible * Rename "CursorContext" to "OverlayContext" * Extract CursorCoords type, add rect collection types for readability * Fix type error * Remerge collaborator styling fix from #75700 * Deduplicate selection rects when text is formatted * Fix merge of view.getComputedStyle() fix from #75652 Co-authored-by: alecgeatches <[email protected]> Co-authored-by: maxschmeling <[email protected]> Co-authored-by: chriszarate <[email protected]>
1 parent 421782e commit a246a38

12 files changed

Lines changed: 1166 additions & 290 deletions

File tree

packages/core-data/src/awareness/post-editor-awareness.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
SelectionType,
2323
} from '../utils/crdt-user-selections';
2424

25+
import { SelectionDirection } from '../types';
2526
import type { SelectionState, WPBlockSelection } from '../types';
2627
import type { YBlocks } from '../utils/crdt-blocks';
2728
import type {
@@ -69,6 +70,18 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
6970
let selectionEnd = getSelectionEnd();
7071
let localCursorTimeout: NodeJS.Timeout | null = null;
7172

73+
// During rapid selection changes (e.g. undo restoring content and
74+
// selection), the debounce discards intermediate events. If we use the
75+
// last intermediate state instead of the overall change it can produce
76+
// the wrong direction.
77+
// Use selectionBeforeDebounce to capture the selection state from
78+
// before the debounce window so that direction is computed across the
79+
// full window when it fires.
80+
let selectionBeforeDebounce: {
81+
start: WPBlockSelection;
82+
end: WPBlockSelection;
83+
} | null = null;
84+
7285
subscribe( () => {
7386
const newSelectionStart = getSelectionStart();
7487
const newSelectionEnd = getSelectionEnd();
@@ -80,6 +93,15 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
8093
return;
8194
}
8295

96+
// On the first change of a debounce window, snapshot the state
97+
// we're moving away from.
98+
if ( ! selectionBeforeDebounce ) {
99+
selectionBeforeDebounce = {
100+
start: selectionStart,
101+
end: selectionEnd,
102+
};
103+
}
104+
83105
selectionStart = newSelectionStart;
84106
selectionEnd = newSelectionEnd;
85107

@@ -103,10 +125,29 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
103125
}
104126

105127
localCursorTimeout = setTimeout( () => {
128+
// Compute direction across the full debounce window.
129+
const selectionStateOptions: {
130+
selectionDirection?: SelectionDirection;
131+
} = {};
132+
133+
if ( selectionBeforeDebounce ) {
134+
selectionStateOptions.selectionDirection =
135+
detectSelectionDirection(
136+
selectionBeforeDebounce.start,
137+
selectionBeforeDebounce.end,
138+
selectionStart,
139+
selectionEnd
140+
);
141+
142+
// Reset debounced selection state.
143+
selectionBeforeDebounce = null;
144+
}
145+
106146
const selectionState = getSelectionState(
107147
selectionStart,
108148
selectionEnd,
109-
this.doc
149+
this.doc,
150+
selectionStateOptions
110151
);
111152

112153
this.setThrottledLocalStateField(
@@ -325,3 +366,50 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
325366
};
326367
}
327368
}
369+
370+
/**
371+
* Detect the direction of a selection change by comparing old and new edges.
372+
*
373+
* When the user extends a selection backward (e.g. Shift+Left), the
374+
* selectionStart edge moves while selectionEnd stays fixed, so the caret
375+
* is at the start. The reverse is true for forward extension.
376+
*
377+
* @param prevStart - The previous selectionStart.
378+
* @param prevEnd - The previous selectionEnd.
379+
* @param newStart - The new selectionStart.
380+
* @param newEnd - The new selectionEnd.
381+
* @return The detected direction, defaulting to Forward when indeterminate.
382+
*/
383+
function detectSelectionDirection(
384+
prevStart: WPBlockSelection,
385+
prevEnd: WPBlockSelection,
386+
newStart: WPBlockSelection,
387+
newEnd: WPBlockSelection
388+
): SelectionDirection {
389+
const startMoved = ! areBlockSelectionsEqual( prevStart, newStart );
390+
const endMoved = ! areBlockSelectionsEqual( prevEnd, newEnd );
391+
392+
if ( startMoved && ! endMoved ) {
393+
return SelectionDirection.Backward;
394+
}
395+
396+
return SelectionDirection.Forward;
397+
}
398+
399+
/**
400+
* Compare two WPBlockSelection objects by value.
401+
*
402+
* @param a - First selection.
403+
* @param b - Second selection.
404+
* @return True if all fields are equal.
405+
*/
406+
function areBlockSelectionsEqual(
407+
a: WPBlockSelection,
408+
b: WPBlockSelection
409+
): boolean {
410+
return (
411+
a.clientId === b.clientId &&
412+
a.attributeKey === b.attributeKey &&
413+
a.offset === b.offset
414+
);
415+
}

packages/core-data/src/hooks/use-post-editor-awareness-state.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,9 @@ import type {
1414
PostSaveEvent,
1515
YDocDebugData,
1616
} from '../awareness/types';
17-
import type { SelectionState } from '../types';
17+
import type { SelectionState, ResolvedSelection } from '../types';
1818
import type { PostEditorAwareness } from '../awareness/post-editor-awareness';
1919

20-
interface ResolvedSelection {
21-
textIndex: number | null;
22-
localClientId: string | null;
23-
}
24-
2520
interface AwarenessState {
2621
activeCollaborators: ActiveCollaborator[];
2722
resolveSelection: ( selection: SelectionState ) => ResolvedSelection;

packages/core-data/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ register( store ); // Register store after unlocking private selectors to allow
138138
* based on their values (they blur to string type).
139139
*/
140140
export { SelectionType } from './utils/crdt-user-selections';
141+
export { SelectionDirection } from './types';
141142

142143
export { default as EntityProvider } from './entity-provider';
143144
export * from './entity-provider';

packages/core-data/src/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,16 @@ export type CursorPosition = {
6666
absoluteOffset: number;
6767
};
6868

69+
/**
70+
* The direction of a text selection, indicating where the caret sits.
71+
*/
72+
export enum SelectionDirection {
73+
/** The caret is at the end of the selection (default / left-to-right). */
74+
Forward = 'f',
75+
/** The caret is at the start of the selection (right-to-left). */
76+
Backward = 'b',
77+
}
78+
6979
export type SelectionNone = {
7080
// The user has not made a selection.
7181
type: SelectionType.None;
@@ -86,6 +96,8 @@ export type SelectionInOneBlock = {
8696
type: SelectionType.SelectionInOneBlock;
8797
cursorStartPosition: CursorPosition;
8898
cursorEndPosition: CursorPosition;
99+
// The direction of the selection, indicating where the caret sits.
100+
selectionDirection?: SelectionDirection;
89101
};
90102

91103
export type SelectionInMultipleBlocks = {
@@ -95,6 +107,8 @@ export type SelectionInMultipleBlocks = {
95107
type: SelectionType.SelectionInMultipleBlocks;
96108
cursorStartPosition: CursorPosition;
97109
cursorEndPosition: CursorPosition;
110+
// The direction of the selection, indicating where the caret sits.
111+
selectionDirection?: SelectionDirection;
98112
};
99113

100114
export type SelectionWholeBlock = {
@@ -111,3 +125,8 @@ export type SelectionState =
111125
| SelectionInOneBlock
112126
| SelectionInMultipleBlocks
113127
| SelectionWholeBlock;
128+
129+
export interface ResolvedSelection {
130+
textIndex: number | null;
131+
localClientId: string | null;
132+
}

packages/core-data/src/utils/crdt-user-selections.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,17 @@ import { CRDT_RECORD_MAP_KEY } from '../sync';
1313
import type { YPostRecord } from './crdt';
1414
import type { YBlock, YBlocks } from './crdt-blocks';
1515
import { getRootMap } from './crdt-utils';
16-
import type {
17-
AbsoluteBlockIndexPath,
18-
WPBlockSelection,
19-
SelectionState,
20-
SelectionNone,
21-
SelectionCursor,
22-
SelectionInOneBlock,
23-
SelectionInMultipleBlocks,
24-
SelectionWholeBlock,
25-
CursorPosition,
16+
import type { SelectionDirection } from '../types';
17+
import {
18+
type AbsoluteBlockIndexPath,
19+
type WPBlockSelection,
20+
type SelectionState,
21+
type SelectionNone,
22+
type SelectionCursor,
23+
type SelectionInOneBlock,
24+
type SelectionInMultipleBlocks,
25+
type SelectionWholeBlock,
26+
type CursorPosition,
2627
} from '../types';
2728

2829
/**
@@ -44,16 +45,20 @@ export enum SelectionType {
4445
* differ between the block-editor store and the Yjs document (e.g. in "Show
4546
* Template" mode).
4647
*
47-
* @param selectionStart - The start position of the selection
48-
* @param selectionEnd - The end position of the selection
49-
* @param yDoc - The Yjs document
48+
* @param selectionStart - The start position of the selection
49+
* @param selectionEnd - The end position of the selection
50+
* @param yDoc - The Yjs document
51+
* @param options - Optional parameters
52+
* @param options.selectionDirection - The direction of the selection (forward or backward)
5053
* @return The SelectionState
5154
*/
5255
export function getSelectionState(
5356
selectionStart: WPBlockSelection,
5457
selectionEnd: WPBlockSelection,
55-
yDoc: Y.Doc
58+
yDoc: Y.Doc,
59+
options?: { selectionDirection?: SelectionDirection }
5660
): SelectionState {
61+
const { selectionDirection } = options ?? {};
5762
const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY );
5863
const yBlocks = ymap.get( 'blocks' );
5964

@@ -122,6 +127,7 @@ export function getSelectionState(
122127
type: SelectionType.SelectionInOneBlock,
123128
cursorStartPosition,
124129
cursorEndPosition,
130+
selectionDirection,
125131
};
126132
}
127133

@@ -137,6 +143,7 @@ export function getSelectionState(
137143
type: SelectionType.SelectionInMultipleBlocks,
138144
cursorStartPosition,
139145
cursorEndPosition,
146+
selectionDirection,
140147
};
141148
}
142149

@@ -315,7 +322,9 @@ export function areSelectionsStatesEqual(
315322
areCursorPositionsEqual(
316323
selection1.cursorEndPosition,
317324
( selection2 as SelectionInOneBlock ).cursorEndPosition
318-
)
325+
) &&
326+
selection1.selectionDirection ===
327+
( selection2 as SelectionInOneBlock ).selectionDirection
319328
);
320329

321330
case SelectionType.SelectionInMultipleBlocks:
@@ -329,7 +338,10 @@ export function areSelectionsStatesEqual(
329338
selection1.cursorEndPosition,
330339
( selection2 as SelectionInMultipleBlocks )
331340
.cursorEndPosition
332-
)
341+
) &&
342+
selection1.selectionDirection ===
343+
( selection2 as SelectionInMultipleBlocks )
344+
.selectionDirection
333345
);
334346
case SelectionType.WholeBlock:
335347
return Y.compareRelativePositions(

0 commit comments

Comments
 (0)