Skip to content
90 changes: 89 additions & 1 deletion packages/core-data/src/awareness/post-editor-awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
SelectionType,
} from '../utils/crdt-user-selections';

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

// During rapid selection changes (e.g. undo restoring content and
// selection), the debounce discards intermediate events. If we use the
// last intermediate state instead of the overall change it can produce
// the wrong direction.
// Use selectionBeforeDebounce to capture the selection state from
// before the debounce window so that direction is computed across the
// full window when it fires.
let selectionBeforeDebounce: {
start: WPBlockSelection;
end: WPBlockSelection;
} | null = null;

subscribe( () => {
const newSelectionStart = getSelectionStart();
const newSelectionEnd = getSelectionEnd();
Expand All @@ -80,6 +93,15 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
return;
}

// On the first change of a debounce window, snapshot the state
// we're moving away from.
if ( ! selectionBeforeDebounce ) {
selectionBeforeDebounce = {
start: selectionStart,
end: selectionEnd,
};
}

selectionStart = newSelectionStart;
selectionEnd = newSelectionEnd;

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

localCursorTimeout = setTimeout( () => {
// Compute direction across the full debounce window.
const selectionStateOptions: {
selectionDirection?: SelectionDirection;
} = {};

if ( selectionBeforeDebounce ) {
selectionStateOptions.selectionDirection =
detectSelectionDirection(
selectionBeforeDebounce.start,
selectionBeforeDebounce.end,
selectionStart,
selectionEnd
);

// Reset debounced selection state.
selectionBeforeDebounce = null;
}

const selectionState = getSelectionState(
selectionStart,
selectionEnd,
this.doc
this.doc,
selectionStateOptions
);

this.setThrottledLocalStateField(
Expand Down Expand Up @@ -325,3 +366,50 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
};
}
}

/**
* Detect the direction of a selection change by comparing old and new edges.
*
* When the user extends a selection backward (e.g. Shift+Left), the
* selectionStart edge moves while selectionEnd stays fixed, so the caret
* is at the start. The reverse is true for forward extension.
*
* @param prevStart - The previous selectionStart.
* @param prevEnd - The previous selectionEnd.
* @param newStart - The new selectionStart.
* @param newEnd - The new selectionEnd.
* @return The detected direction, defaulting to Forward when indeterminate.
*/
function detectSelectionDirection(
prevStart: WPBlockSelection,
prevEnd: WPBlockSelection,
newStart: WPBlockSelection,
newEnd: WPBlockSelection
): SelectionDirection {
const startMoved = ! areBlockSelectionsEqual( prevStart, newStart );
const endMoved = ! areBlockSelectionsEqual( prevEnd, newEnd );

if ( startMoved && ! endMoved ) {
return SelectionDirection.Backward;
}

return SelectionDirection.Forward;
}

/**
* Compare two WPBlockSelection objects by value.
*
* @param a - First selection.
* @param b - Second selection.
* @return True if all fields are equal.
*/
function areBlockSelectionsEqual(
a: WPBlockSelection,
b: WPBlockSelection
): boolean {
return (
a.clientId === b.clientId &&
a.attributeKey === b.attributeKey &&
a.offset === b.offset
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,9 @@ import type {
PostSaveEvent,
YDocDebugData,
} from '../awareness/types';
import type { SelectionState } from '../types';
import type { SelectionState, ResolvedSelection } from '../types';
import type { PostEditorAwareness } from '../awareness/post-editor-awareness';

interface ResolvedSelection {
textIndex: number | null;
localClientId: string | null;
}

interface AwarenessState {
activeCollaborators: ActiveCollaborator[];
resolveSelection: ( selection: SelectionState ) => ResolvedSelection;
Expand Down
1 change: 1 addition & 0 deletions packages/core-data/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ register( store ); // Register store after unlocking private selectors to allow
* based on their values (they blur to string type).
*/
export { SelectionType } from './utils/crdt-user-selections';
export { SelectionDirection } from './types';

export { default as EntityProvider } from './entity-provider';
export * from './entity-provider';
Expand Down
19 changes: 19 additions & 0 deletions packages/core-data/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export type CursorPosition = {
absoluteOffset: number;
};

/**
* The direction of a text selection, indicating where the caret sits.
*/
export enum SelectionDirection {
/** The caret is at the end of the selection (default / left-to-right). */
Forward = 'f',
/** The caret is at the start of the selection (right-to-left). */
Backward = 'b',
}

export type SelectionNone = {
// The user has not made a selection.
type: SelectionType.None;
Expand All @@ -86,6 +96,8 @@ export type SelectionInOneBlock = {
type: SelectionType.SelectionInOneBlock;
cursorStartPosition: CursorPosition;
cursorEndPosition: CursorPosition;
// The direction of the selection, indicating where the caret sits.
selectionDirection?: SelectionDirection;
};

export type SelectionInMultipleBlocks = {
Expand All @@ -95,6 +107,8 @@ export type SelectionInMultipleBlocks = {
type: SelectionType.SelectionInMultipleBlocks;
cursorStartPosition: CursorPosition;
cursorEndPosition: CursorPosition;
// The direction of the selection, indicating where the caret sits.
selectionDirection?: SelectionDirection;
};

export type SelectionWholeBlock = {
Expand All @@ -111,3 +125,8 @@ export type SelectionState =
| SelectionInOneBlock
| SelectionInMultipleBlocks
| SelectionWholeBlock;

export interface ResolvedSelection {
textIndex: number | null;
localClientId: string | null;
}
44 changes: 28 additions & 16 deletions packages/core-data/src/utils/crdt-user-selections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ import { CRDT_RECORD_MAP_KEY } from '../sync';
import type { YPostRecord } from './crdt';
import type { YBlock, YBlocks } from './crdt-blocks';
import { getRootMap } from './crdt-utils';
import type {
AbsoluteBlockIndexPath,
WPBlockSelection,
SelectionState,
SelectionNone,
SelectionCursor,
SelectionInOneBlock,
SelectionInMultipleBlocks,
SelectionWholeBlock,
CursorPosition,
import type { SelectionDirection } from '../types';
import {
type AbsoluteBlockIndexPath,
type WPBlockSelection,
type SelectionState,
type SelectionNone,
type SelectionCursor,
type SelectionInOneBlock,
type SelectionInMultipleBlocks,
type SelectionWholeBlock,
type CursorPosition,
} from '../types';

/**
Expand All @@ -44,16 +45,20 @@ export enum SelectionType {
* differ between the block-editor store and the Yjs document (e.g. in "Show
* Template" mode).
*
* @param selectionStart - The start position of the selection
* @param selectionEnd - The end position of the selection
* @param yDoc - The Yjs document
* @param selectionStart - The start position of the selection
* @param selectionEnd - The end position of the selection
* @param yDoc - The Yjs document
* @param options - Optional parameters
* @param options.selectionDirection - The direction of the selection (forward or backward)
* @return The SelectionState
*/
export function getSelectionState(
selectionStart: WPBlockSelection,
selectionEnd: WPBlockSelection,
yDoc: Y.Doc
yDoc: Y.Doc,
options?: { selectionDirection?: SelectionDirection }
): SelectionState {
const { selectionDirection } = options ?? {};
const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY );
const yBlocks = ymap.get( 'blocks' );

Expand Down Expand Up @@ -122,6 +127,7 @@ export function getSelectionState(
type: SelectionType.SelectionInOneBlock,
cursorStartPosition,
cursorEndPosition,
selectionDirection,
};
}

Expand All @@ -137,6 +143,7 @@ export function getSelectionState(
type: SelectionType.SelectionInMultipleBlocks,
cursorStartPosition,
cursorEndPosition,
selectionDirection,
};
}

Expand Down Expand Up @@ -315,7 +322,9 @@ export function areSelectionsStatesEqual(
areCursorPositionsEqual(
selection1.cursorEndPosition,
( selection2 as SelectionInOneBlock ).cursorEndPosition
)
) &&
selection1.selectionDirection ===
( selection2 as SelectionInOneBlock ).selectionDirection
);

case SelectionType.SelectionInMultipleBlocks:
Expand All @@ -329,7 +338,10 @@ export function areSelectionsStatesEqual(
selection1.cursorEndPosition,
( selection2 as SelectionInMultipleBlocks )
.cursorEndPosition
)
) &&
selection1.selectionDirection ===
( selection2 as SelectionInMultipleBlocks )
.selectionDirection
);
case SelectionType.WholeBlock:
return Y.compareRelativePositions(
Expand Down
Loading
Loading