@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
286 lines (254 loc) • 7.63 kB
text/typescript
/**
* WordPress dependencies
*/
import { Y } from '@wordpress/sync';
/**
* Internal dependencies
*/
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 {
WPBlockSelection,
SelectionState,
SelectionNone,
SelectionCursor,
SelectionInOneBlock,
SelectionInMultipleBlocks,
SelectionWholeBlock,
CursorPosition,
} from '../types';
/**
* The type of selection.
*/
export enum SelectionType {
None = 'none',
Cursor = 'cursor',
SelectionInOneBlock = 'selection-in-one-block',
SelectionInMultipleBlocks = 'selection-in-multiple-blocks',
WholeBlock = 'whole-block',
}
/**
* Converts WordPress block editor selection to a SelectionState.
*
* @param selectionStart - The start position of the selection
* @param selectionEnd - The end position of the selection
* @param yDoc - The Yjs document
* @return The SelectionState
*/
export function getSelectionState(
selectionStart: WPBlockSelection,
selectionEnd: WPBlockSelection,
yDoc: Y.Doc
): SelectionState {
const ymap = getRootMap< YPostRecord >( yDoc, CRDT_RECORD_MAP_KEY );
const yBlocks = ymap.get( 'blocks' ) ?? new Y.Array< YBlock >();
const isSelectionEmpty = Object.keys( selectionStart ).length === 0;
const noSelection: SelectionNone = {
type: SelectionType.None,
};
if ( isSelectionEmpty ) {
// Case 1: No selection
return noSelection;
}
// When the page initially loads, selectionStart can contain an empty object `{}`.
const isSelectionInOneBlock =
selectionStart.clientId === selectionEnd.clientId;
const isCursorOnly =
isSelectionInOneBlock && selectionStart.offset === selectionEnd.offset;
const isSelectionAWholeBlock =
isSelectionInOneBlock &&
selectionStart.offset === undefined &&
selectionEnd.offset === undefined;
if ( isSelectionAWholeBlock ) {
// Case 2: A whole block is selected.
return {
type: SelectionType.WholeBlock,
blockId: selectionStart.clientId,
};
} else if ( isCursorOnly ) {
// Case 3: Cursor only, no text selected
const cursorPosition = getCursorPosition( selectionStart, yBlocks );
if ( ! cursorPosition ) {
// If we can't find the cursor position in block text, treat it as a non-selection.
return noSelection;
}
return {
type: SelectionType.Cursor,
blockId: selectionStart.clientId,
cursorPosition,
};
} else if ( isSelectionInOneBlock ) {
// Case 4: Selection in a single block
const cursorStartPosition = getCursorPosition(
selectionStart,
yBlocks
);
const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks );
if ( ! cursorStartPosition || ! cursorEndPosition ) {
// If we can't find the cursor positions in block text, treat it as a non-selection.
return noSelection;
}
return {
type: SelectionType.SelectionInOneBlock,
blockId: selectionStart.clientId,
cursorStartPosition,
cursorEndPosition,
};
}
// Caes 5: Selection in multiple blocks
const cursorStartPosition = getCursorPosition( selectionStart, yBlocks );
const cursorEndPosition = getCursorPosition( selectionEnd, yBlocks );
if ( ! cursorStartPosition || ! cursorEndPosition ) {
// If we can't find the cursor positions in block text, treat it as a non-selection.
return noSelection;
}
return {
type: SelectionType.SelectionInMultipleBlocks,
blockStartId: selectionStart.clientId,
blockEndId: selectionEnd.clientId,
cursorStartPosition,
cursorEndPosition,
};
}
/**
* Get the cursor position from a selection.
*
* @param selection - The selection.
* @param blocks - The blocks to search through.
* @return The cursor position, or null if not found.
*/
function getCursorPosition(
selection: WPBlockSelection,
blocks: YBlocks
): CursorPosition | null {
const block = findBlockByClientId( selection.clientId, blocks );
if (
! block ||
! selection.attributeKey ||
undefined === selection.offset
) {
return null;
}
const attributes = block.get( 'attributes' );
const currentYText = attributes?.get( selection.attributeKey ) as Y.Text;
const relativePosition = Y.createRelativePositionFromTypeIndex(
currentYText,
selection.offset
);
return {
relativePosition,
absoluteOffset: selection.offset,
};
}
/**
* Find a block by its client ID.
*
* @param blockId - The client ID of the block.
* @param blocks - The blocks to search through.
* @return The block if found, null otherwise.
*/
function findBlockByClientId(
blockId: string,
blocks: YBlocks
): YBlock | null {
for ( const block of blocks ) {
if ( block.get( 'clientId' ) === blockId ) {
return block;
}
const innerBlocks = block.get( 'innerBlocks' );
if ( innerBlocks && innerBlocks.length > 0 ) {
const innerBlock = findBlockByClientId( blockId, innerBlocks );
if ( innerBlock ) {
return innerBlock;
}
}
}
return null;
}
/**
* Check if two selection states are equal.
*
* @param selection1 - The first selection state.
* @param selection2 - The second selection state.
* @return True if the selection states are equal, false otherwise.
*/
export function areSelectionsStatesEqual(
selection1: SelectionState,
selection2: SelectionState
): boolean {
if ( selection1.type !== selection2.type ) {
return false;
}
switch ( selection1.type ) {
case SelectionType.None:
return true;
case SelectionType.Cursor:
return (
selection1.blockId ===
( selection2 as SelectionCursor ).blockId &&
areCursorPositionsEqual(
selection1.cursorPosition,
( selection2 as SelectionCursor ).cursorPosition
)
);
case SelectionType.SelectionInOneBlock:
return (
selection1.blockId ===
( selection2 as SelectionInOneBlock ).blockId &&
areCursorPositionsEqual(
selection1.cursorStartPosition,
( selection2 as SelectionInOneBlock ).cursorStartPosition
) &&
areCursorPositionsEqual(
selection1.cursorEndPosition,
( selection2 as SelectionInOneBlock ).cursorEndPosition
)
);
case SelectionType.SelectionInMultipleBlocks:
return (
selection1.blockStartId ===
( selection2 as SelectionInMultipleBlocks ).blockStartId &&
selection1.blockEndId ===
( selection2 as SelectionInMultipleBlocks ).blockEndId &&
areCursorPositionsEqual(
selection1.cursorStartPosition,
( selection2 as SelectionInMultipleBlocks )
.cursorStartPosition
) &&
areCursorPositionsEqual(
selection1.cursorEndPosition,
( selection2 as SelectionInMultipleBlocks )
.cursorEndPosition
)
);
case SelectionType.WholeBlock:
return (
selection1.blockId ===
( selection2 as SelectionWholeBlock ).blockId
);
default:
return false;
}
}
/**
* Check if two cursor positions are equal.
*
* @param cursorPosition1 - The first cursor position.
* @param cursorPosition2 - The second cursor position.
* @return True if the cursor positions are equal, false otherwise.
*/
function areCursorPositionsEqual(
cursorPosition1: CursorPosition,
cursorPosition2: CursorPosition
): boolean {
const isRelativePositionEqual =
JSON.stringify( cursorPosition1.relativePosition ) ===
JSON.stringify( cursorPosition2.relativePosition );
// Ensure a change in calculated absolute offset results in a treating the cursor as modified.
// This is necessary because Y.Text relative positions can remain the same after text changes.
const isAbsoluteOffsetEqual =
cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset;
return isRelativePositionEqual && isAbsoluteOffsetEqual;
}