diff --git a/packages/block-editor/src/components/writing-flow/use-selection-observer.js b/packages/block-editor/src/components/writing-flow/use-selection-observer.js
index a0efc8c6f158e6..75bb5d4b24341d 100644
--- a/packages/block-editor/src/components/writing-flow/use-selection-observer.js
+++ b/packages/block-editor/src/components/writing-flow/use-selection-observer.js
@@ -219,35 +219,66 @@ export default function useSelectionObserver() {
// (e.g. the user started dragging from the block
// wrapper padding), dispatch a full
// selectionChange so the format toolbar appears.
- const richTextElement =
+ // If it spans multiple RichText fields in the same
+ // block, preserve each endpoint's attribute key.
+ const richTextElementStart =
+ ! selection.isCollapsed &&
+ getRichTextElement( startNode );
+ const richTextElementEnd =
! selection.isCollapsed &&
- ( getRichTextElement( startNode ) ||
- getRichTextElement( endNode ) );
+ getRichTextElement( endNode );
+ const richTextElement =
+ richTextElementStart || richTextElementEnd;
+ const hasMultipleRichTextElements =
+ richTextElementStart &&
+ richTextElementEnd &&
+ richTextElementStart !== richTextElementEnd;
if (
richTextElement &&
- ownerDocument.activeElement !== richTextElement
+ ( hasMultipleRichTextElements ||
+ ownerDocument.activeElement !==
+ richTextElement )
) {
const range = selection.getRangeAt( 0 );
- const richTextData = create( {
- element: richTextElement,
+ const startElement =
+ richTextElementStart || richTextElement;
+ const endElement =
+ richTextElementEnd || richTextElement;
+ const richTextDataStart = create( {
+ element: startElement,
range,
__unstableIsEditableTree: true,
} );
+ const richTextDataEnd =
+ startElement === endElement
+ ? richTextDataStart
+ : create( {
+ element: endElement,
+ range,
+ __unstableIsEditableTree: true,
+ } );
selectionChange( {
start: {
clientId: startClientId,
attributeKey:
- richTextElement.dataset
+ startElement.dataset
.wpBlockAttributeKey,
- offset: richTextData.start ?? 0,
+ offset: hasMultipleRichTextElements
+ ? richTextDataStart.start ??
+ richTextDataStart.end ??
+ 0
+ : richTextDataStart.start ?? 0,
},
end: {
clientId: startClientId,
attributeKey:
- richTextElement.dataset
- .wpBlockAttributeKey,
- offset: richTextData.end,
+ endElement.dataset.wpBlockAttributeKey,
+ offset: hasMultipleRichTextElements
+ ? richTextDataEnd.end ??
+ richTextDataEnd.start ??
+ 0
+ : richTextDataEnd.end,
},
} );
} else {
diff --git a/packages/core-data/src/awareness/block-lookup.ts b/packages/core-data/src/awareness/block-lookup.ts
index 6c200bedace6a1..462b3cbe7cd214 100644
--- a/packages/core-data/src/awareness/block-lookup.ts
+++ b/packages/core-data/src/awareness/block-lookup.ts
@@ -41,12 +41,7 @@ export function getContainingBlockYMap(
while ( current ) {
const parent = current.parent;
- if (
- parent instanceof Y.Map &&
- parent.parent instanceof Y.Array &&
- parent.get( 'clientId' ) !== undefined &&
- parent.get( 'innerBlocks' ) instanceof Y.Array
- ) {
+ if ( parent instanceof Y.Map && getBlockPathInYdoc( parent ) ) {
return parent;
}
@@ -93,19 +88,27 @@ export function getBlockPathInYdoc(
path.unshift( index );
- // Walk up: is the parent array's parent a block Y.Map or the root?
- const grandparent = parentArray.parent;
+ const owner = parentArray.parent;
+ if ( ! ( owner instanceof Y.Map ) ) {
+ return null;
+ }
+
+ if ( ! owner.parent && owner.get( 'blocks' ) === parentArray ) {
+ return path;
+ }
+
if (
- grandparent instanceof Y.Map &&
- grandparent.get( 'clientId' ) !== undefined
+ owner.get( 'innerBlocks' ) === parentArray &&
+ owner.get( 'clientId' ) !== undefined
) {
- current = grandparent; // It's a block, keep going.
- } else {
- break; // It's the root map, done.
+ current = owner;
+ continue;
}
+
+ return null;
}
- return path;
+ return null;
}
/**
diff --git a/packages/core-data/src/awareness/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts
index b17c606a73f293..7bb03112e086fd 100644
--- a/packages/core-data/src/awareness/post-editor-awareness.ts
+++ b/packages/core-data/src/awareness/post-editor-awareness.ts
@@ -22,6 +22,8 @@ import {
import { STORE_NAME as coreStore } from '../name';
import {
asHtmlStringIndex,
+ getAttributeKeyForYText,
+ getYTextByAttributeKey,
htmlIndexToRichTextOffset,
} from '../utils/crdt-utils';
import {
@@ -307,6 +309,36 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
const yType = getContainingBlockYMap( absolutePosition.type );
const path = yType ? getBlockPathInYdoc( yType ) : null;
const localClientId = path ? resolveBlockClientIdByPath( path ) : null;
+ const attributes = yType?.get( 'attributes' );
+ let attributeKey: string | null = null;
+
+ if (
+ attributes instanceof Y.Map &&
+ absolutePosition.type instanceof Y.Text
+ ) {
+ attributeKey = getAttributeKeyForYText(
+ attributes,
+ absolutePosition.type
+ );
+
+ const senderAttributeKey = cursorPos.attributeKey;
+ if (
+ ! attributeKey &&
+ senderAttributeKey &&
+ getYTextByAttributeKey( attributes, senderAttributeKey ) ===
+ absolutePosition.type
+ ) {
+ attributeKey = senderAttributeKey;
+ }
+ }
+
+ if ( ! localClientId || ! attributeKey ) {
+ return {
+ richTextOffset: null,
+ localClientId: null,
+ attributeKey: null,
+ };
+ }
return {
richTextOffset: htmlIndexToRichTextOffset(
@@ -314,7 +346,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
asHtmlStringIndex( absolutePosition.index )
),
localClientId,
- attributeKey: cursorPos.attributeKey ?? null,
+ attributeKey,
};
}
diff --git a/packages/core-data/src/awareness/test/block-lookup.ts b/packages/core-data/src/awareness/test/block-lookup.ts
index 2266b67a5918db..d8a5b8d2404fc3 100644
--- a/packages/core-data/src/awareness/test/block-lookup.ts
+++ b/packages/core-data/src/awareness/test/block-lookup.ts
@@ -310,6 +310,28 @@ describe( 'getContainingBlockYMap', () => {
expect( getContainingBlockYMap( text ) ).toBe( block );
} );
+
+ it( 'should skip block-shaped nested array items that look like blocks', () => {
+ const block = createTestYBlock( 'block' );
+ const attributes = new Y.Map< any >();
+ const cards = new Y.Array< Y.Map< any > >();
+ const blockLikeCard = new Y.Map< any >();
+ const text = new Y.Text( 'Nested card text' );
+ blockLikeCard.set( 'clientId', 'attribute-card-client-id' );
+ blockLikeCard.set( 'innerBlocks', new Y.Array() );
+ blockLikeCard.set( 'content', text );
+ cards.push( [ blockLikeCard ] );
+ attributes.set( 'cards', cards );
+ block.set( 'attributes', attributes );
+
+ const ydoc = new Y.Doc();
+ const rootMap = ydoc.getMap( 'test' );
+ const blocks = new Y.Array< Y.Map< any > >();
+ rootMap.set( 'blocks', blocks );
+ blocks.push( [ block ] );
+
+ expect( getContainingBlockYMap( text ) ).toBe( block );
+ } );
} );
describe( 'resolveBlockClientIdByPath', () => {
diff --git a/packages/core-data/src/awareness/test/post-editor-awareness.ts b/packages/core-data/src/awareness/test/post-editor-awareness.ts
index 3d98ea40768378..c21ee60764cca4 100644
--- a/packages/core-data/src/awareness/test/post-editor-awareness.ts
+++ b/packages/core-data/src/awareness/test/post-editor-awareness.ts
@@ -707,7 +707,7 @@ describe( 'PostEditorAwareness', () => {
expect( result.attributeKey ).toBeNull();
} );
- test( 'should pass through nested attributeKey for a cursor selection', () => {
+ test( 'should derive the current attributeKey for a cursor selection', () => {
const awareness = new PostEditorAwareness(
doc,
'postType',
@@ -740,7 +740,7 @@ describe( 'PostEditorAwareness', () => {
const result =
awareness.convertSelectionStateToAbsolute( selection );
- expect( result.attributeKey ).toBe( 'body.0.cells.0.content' );
+ expect( result.attributeKey ).toBe( 'content' );
} );
} );
@@ -1242,6 +1242,74 @@ describe( 'PostEditorAwareness', () => {
nestedDoc.destroy();
}
);
+
+ test( 'resolves block-shaped nested array item rich text to the containing block', () => {
+ const paragraph = createYBlock( 'yjs-paragraph', 'core/paragraph', {
+ textContent: 'Root paragraph before card block',
+ } );
+ const cardBlock = new Y.Map();
+ cardBlock.set( 'clientId', 'yjs-card-list' );
+ cardBlock.set( 'name', 'test/card-list' );
+
+ const attrs = new Y.Map();
+ const cards = new Y.Array();
+ const card = new Y.Map();
+ const cardContent = new Y.Text( 'Nested card cursor target' );
+ card.set( 'clientId', 'attribute-card-0' );
+ card.set( 'innerBlocks', new Y.Array() );
+ card.set( 'content', cardContent );
+ cards.push( [ card ] );
+ attrs.set( 'cards', cards );
+ cardBlock.set( 'attributes', attrs );
+ cardBlock.set( 'innerBlocks', new Y.Array() );
+
+ const nestedDoc = createTestDocWithBlocks( [
+ paragraph,
+ cardBlock,
+ ] );
+
+ mockBlockEditorStore( {
+ blocks: [
+ {
+ clientId: 'local-paragraph',
+ innerBlocks: [],
+ },
+ {
+ clientId: 'local-card-list',
+ innerBlocks: [],
+ },
+ ],
+ } );
+
+ const initialOffset = 6;
+ const relativePosition = Y.createRelativePositionFromTypeIndex(
+ cardContent,
+ initialOffset
+ );
+ const awareness = new PostEditorAwareness(
+ nestedDoc,
+ 'postType',
+ 'post',
+ 123
+ );
+ const selection: SelectionCursor = {
+ type: SelectionType.Cursor,
+ cursorPosition: {
+ relativePosition,
+ absoluteOffset: initialOffset,
+ attributeKey: 'cards.0.content',
+ },
+ };
+
+ const result =
+ awareness.convertSelectionStateToAbsolute( selection );
+
+ expect( result.richTextOffset ).toBe( initialOffset );
+ expect( result.localClientId ).toBe( 'local-card-list' );
+ expect( result.attributeKey ).toBe( 'cards.0.content' );
+
+ nestedDoc.destroy();
+ } );
} );
describe( 'template mode (core/post-content handling)', () => {
diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts
index 0c5e56ff33c7a2..c963db4494a4cb 100644
--- a/packages/core-data/src/utils/crdt-blocks.ts
+++ b/packages/core-data/src/utils/crdt-blocks.ts
@@ -737,7 +737,7 @@ function mergeYArray(
newElement,
query,
cursorPosition,
- cursorScope
+ appendCursorScopeKey( cursorScope, ( left + i ).toString() )
);
} else {
// Element is the wrong type (e.g. partial migration) or the
@@ -874,7 +874,7 @@ function mergeYMapValues(
yMap,
key,
cursorPosition,
- cursorScope
+ appendCursorScopeKey( cursorScope, key )
);
}
@@ -939,6 +939,16 @@ interface RichTextCursorScope {
clientId: string | undefined;
}
+function appendCursorScopeKey(
+ cursorScope: RichTextCursorScope,
+ key: string
+): RichTextCursorScope {
+ return {
+ ...cursorScope,
+ attributeKey: `${ cursorScope.attributeKey }.${ key }`,
+ };
+}
+
interface DeltaWithOps {
ops: Parameters< Y.Text[ 'applyDelta' ] >[ 0 ];
}
diff --git a/packages/core-data/src/utils/crdt-selection.ts b/packages/core-data/src/utils/crdt-selection.ts
index 61cdf94932aced..d41935b6d5c7ef 100644
--- a/packages/core-data/src/utils/crdt-selection.ts
+++ b/packages/core-data/src/utils/crdt-selection.ts
@@ -20,6 +20,8 @@ import {
import {
asHtmlStringIndex,
findBlockByClientIdInDoc,
+ getAttributeKeyForYText,
+ getYTextByAttributeKey,
htmlIndexToRichTextOffset,
} from './crdt-utils';
import type { WPBlockSelection, WPSelection } from '../types';
@@ -67,16 +69,33 @@ function convertYSelectionToBlockSelection(
): WPBlockSelection | null {
if ( ySelection.type === YSelectionType.RelativeSelection ) {
const { relativePosition, attributeKey, clientId } = ySelection;
+ const block = findBlockByClientIdInDoc( clientId, ydoc );
+ const attributes = block?.get( 'attributes' );
const absolutePosition = Y.createAbsolutePositionFromRelativePosition(
relativePosition,
ydoc
);
- if ( absolutePosition ) {
+ if (
+ absolutePosition &&
+ attributes instanceof Y.Map &&
+ absolutePosition.type instanceof Y.Text
+ ) {
+ const currentAttributeKey =
+ getAttributeKeyForYText( attributes, absolutePosition.type ) ??
+ ( getYTextByAttributeKey( attributes, attributeKey ) ===
+ absolutePosition.type
+ ? attributeKey
+ : null );
+
+ if ( ! currentAttributeKey ) {
+ return null;
+ }
+
return {
clientId,
- attributeKey,
+ attributeKey: currentAttributeKey,
offset: htmlIndexToRichTextOffset(
absolutePosition.type.toString(),
asHtmlStringIndex( absolutePosition.index )
diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts
index 04c227eb20a76b..a9dc0ad16e8895 100644
--- a/packages/core-data/src/utils/crdt-user-selections.ts
+++ b/packages/core-data/src/utils/crdt-user-selections.ts
@@ -385,6 +385,10 @@ function areCursorPositionsEqual(
// This is necessary because Y.Text relative positions can remain the same after text changes.
const isAbsoluteOffsetEqual =
cursorPosition1.absoluteOffset === cursorPosition2.absoluteOffset;
+ const isAttributeKeyEqual =
+ cursorPosition1.attributeKey === cursorPosition2.attributeKey;
- return isRelativePositionEqual && isAbsoluteOffsetEqual;
+ return (
+ isRelativePositionEqual && isAbsoluteOffsetEqual && isAttributeKeyEqual
+ );
}
diff --git a/packages/core-data/src/utils/crdt-utils.ts b/packages/core-data/src/utils/crdt-utils.ts
index ad9cbc03c10d84..e902121f90656c 100644
--- a/packages/core-data/src/utils/crdt-utils.ts
+++ b/packages/core-data/src/utils/crdt-utils.ts
@@ -166,6 +166,83 @@ export function getYTextByAttributeKey(
return value instanceof Y.Text ? value : null;
}
+/**
+ * Resolve the current RichText attribute key for a Y.Text by walking the
+ * block attributes tree. Direct top-level keys are checked first to preserve
+ * the lookup semantics of getYTextByAttributeKey for keys that contain dots.
+ *
+ * @param attributes The block attributes map.
+ * @param yText The Y.Text to locate.
+ * @return The current attribute key, or null when no representable key exists.
+ */
+export function getAttributeKeyForYText(
+ attributes: Y.Map< unknown >,
+ yText: Y.Text
+): string | null {
+ for ( const key of attributes.keys() ) {
+ if ( attributes.get( key ) === yText ) {
+ return key;
+ }
+ }
+
+ for ( const key of attributes.keys() ) {
+ if ( key.includes( '.' ) ) {
+ continue;
+ }
+
+ const path = findYTextPath( attributes.get( key ), yText, [ key ] );
+ if ( path ) {
+ return path.join( '.' );
+ }
+ }
+
+ return null;
+}
+
+function findYTextPath(
+ value: unknown,
+ yText: Y.Text,
+ path: string[]
+): string[] | null {
+ if ( value === yText ) {
+ return path;
+ }
+
+ if ( value instanceof Y.Text ) {
+ return null;
+ }
+
+ if ( value instanceof Y.Map ) {
+ for ( const key of value.keys() ) {
+ if ( key.includes( '.' ) ) {
+ continue;
+ }
+
+ const nestedPath = findYTextPath( value.get( key ), yText, [
+ ...path,
+ key,
+ ] );
+ if ( nestedPath ) {
+ return nestedPath;
+ }
+ }
+ }
+
+ if ( value instanceof Y.Array ) {
+ for ( let index = 0; index < value.length; index++ ) {
+ const nestedPath = findYTextPath( value.get( index ), yText, [
+ ...path,
+ index.toString(),
+ ] );
+ if ( nestedPath ) {
+ return nestedPath;
+ }
+ }
+ }
+
+ return null;
+}
+
/**
* Given a block ID and a Y.Doc, find the block in the document.
*
diff --git a/packages/core-data/src/utils/test/crdt-utils.ts b/packages/core-data/src/utils/test/crdt-utils.ts
index 16333318deec4d..a54274ac7b9966 100644
--- a/packages/core-data/src/utils/test/crdt-utils.ts
+++ b/packages/core-data/src/utils/test/crdt-utils.ts
@@ -10,6 +10,7 @@ import { Y } from '@wordpress/sync';
import {
asHtmlStringIndex,
asRichTextOffset,
+ getAttributeKeyForYText,
getYTextByAttributeKey,
htmlIndexToRichTextOffset as typedHtmlIndexToRichTextOffset,
richTextOffsetToHtmlIndex as typedRichTextOffsetToHtmlIndex,
@@ -79,6 +80,53 @@ describe( 'getYTextByAttributeKey', () => {
} );
} );
+describe( 'getAttributeKeyForYText', () => {
+ it( 'returns a top-level rich-text attribute key', () => {
+ const attributes = createAttachedAttributes();
+ const text = new Y.Text( 'Top level' );
+ attributes.set( 'content', text );
+
+ expect( getAttributeKeyForYText( attributes, text ) ).toBe( 'content' );
+ } );
+
+ it( 'returns the current nested path after an array insertion', () => {
+ const attributes = createAttachedAttributes();
+ const body = new Y.Array< Y.Map< unknown > >();
+ const firstRow = new Y.Map< unknown >();
+ const insertedRow = new Y.Map< unknown >();
+ const cells = new Y.Array< Y.Map< unknown > >();
+ const cell = new Y.Map< unknown >();
+ const text = new Y.Text( 'Cell text' );
+
+ cell.set( 'content', text );
+ cells.push( [ cell ] );
+ firstRow.set( 'cells', cells );
+ body.push( [ firstRow ] );
+ attributes.set( 'body', body );
+
+ expect( getAttributeKeyForYText( attributes, text ) ).toBe(
+ 'body.0.cells.0.content'
+ );
+
+ insertedRow.set( 'cells', new Y.Array() );
+ body.insert( 0, [ insertedRow ] );
+
+ expect( getAttributeKeyForYText( attributes, text ) ).toBe(
+ 'body.1.cells.0.content'
+ );
+ } );
+
+ it( 'prefers direct top-level keys that contain dots', () => {
+ const attributes = createAttachedAttributes();
+ const text = new Y.Text( 'Direct dotted key' );
+ attributes.set( 'body.0.content', text );
+
+ expect( getAttributeKeyForYText( attributes, text ) ).toBe(
+ 'body.0.content'
+ );
+ } );
+} );
+
describe( 'htmlIndexToRichTextOffset', () => {
it( 'returns the index unchanged when there are no tags', () => {
expect( htmlIndexToRichTextOffset( 'hello world', 5 ) ).toBe( 5 );
diff --git a/packages/editor/src/components/collaborators-overlay/compute-selection.ts b/packages/editor/src/components/collaborators-overlay/compute-selection.ts
index 43787331a7d77a..8c20b27606e89a 100644
--- a/packages/editor/src/components/collaborators-overlay/compute-selection.ts
+++ b/packages/editor/src/components/collaborators-overlay/compute-selection.ts
@@ -19,7 +19,8 @@ interface OverlayContext {
/** Selection rects and the resolved block element for a single-block selection. */
interface SingleBlockResult {
rects: SelectionRect[];
- blockElement: HTMLElement | null;
+ startElement: HTMLElement | null;
+ endElement: HTMLElement | null;
}
/** Selection rects and the resolved block elements for a multi-block selection. */
@@ -40,11 +41,12 @@ export interface SelectionVisual {
* Resolve the most specific editor element the selection refers to.
*
* When the sender carries an `attributeKey`, narrow to the RichText element
- * matching `data-wp-block-attribute-key` inside the block. This is what makes
- * cursor placement work for blocks with multiple RichText fields (e.g.
+ * matching `data-wp-block-attribute-key` on or inside the block. This is what
+ * makes cursor placement work for blocks with multiple RichText fields (e.g.
* `core/table` cells: `body.0.cells.0.content`, etc.). Falls back to the
- * block element when `attributeKey` is missing (WholeBlock selections,
- * older senders, or DOM lookup miss).
+ * block element only when `attributeKey` is missing (WholeBlock selections
+ * or older senders). Keyed selections must resolve to the exact RichText
+ * target because their offsets are local to that target.
*
* @param editorDocument - The editor document.
* @param resolvedSelection - The resolved selection.
@@ -58,19 +60,36 @@ function resolveTargetElement(
return null;
}
- const blockElement = editorDocument.querySelector< HTMLElement >(
- `[data-block="${ resolvedSelection.localClientId }"]`
+ const blockElement = Array.from(
+ editorDocument.querySelectorAll< HTMLElement >( '[data-block]' )
+ ).find(
+ ( element ) =>
+ element.getAttribute( 'data-block' ) ===
+ resolvedSelection.localClientId
);
if ( ! blockElement || ! resolvedSelection.attributeKey ) {
+ return blockElement ?? null;
+ }
+
+ if (
+ blockElement.getAttribute( 'data-wp-block-attribute-key' ) ===
+ resolvedSelection.attributeKey
+ ) {
return blockElement;
}
- const attrKey = CSS.escape( resolvedSelection.attributeKey );
return (
- blockElement.querySelector< HTMLElement >(
- `[data-wp-block-attribute-key="${ attrKey }"]`
- ) ?? blockElement
+ Array.from(
+ blockElement.querySelectorAll< HTMLElement >(
+ '[data-wp-block-attribute-key]'
+ )
+ ).find(
+ ( element ) =>
+ element.getAttribute( 'data-wp-block-attribute-key' ) ===
+ resolvedSelection.attributeKey &&
+ element.closest( '[data-block]' ) === blockElement
+ ) ?? null
);
}
@@ -118,13 +137,17 @@ function computeCursorOnly(
start: ResolvedSelection,
overlayContext: OverlayContext
): SelectionVisual {
- if ( ! start.localClientId ) {
+ if ( ! start.localClientId || start.richTextOffset === null ) {
return {};
}
const targetElement = resolveTargetElement(
overlayContext.editorDocument,
start
);
+ if ( ! targetElement ) {
+ return {};
+ }
+
return {
coords: getCursorPosition(
start.richTextOffset,
@@ -170,8 +193,7 @@ function computeTextSelection(
if ( selection.type === SelectionType.SelectionInOneBlock ) {
const result = computeSingleBlockRects( start, end, overlayContext );
allRects = result.rects;
- // Single block: start and end share the same block element.
- activeEndBlock = result.blockElement;
+ activeEndBlock = isReverse ? result.startElement : result.endElement;
} else {
const result = computeMultiBlockRects( start, end, overlayContext );
allRects = result.rects;
@@ -199,6 +221,9 @@ function computeTextSelection(
overlayContext.editorDocument,
start
);
+ if ( ! startBlock ) {
+ return {};
+ }
return {
coords: getCursorPosition(
@@ -223,30 +248,119 @@ function computeSingleBlockRects(
end: ResolvedSelection,
overlayContext: OverlayContext
): SingleBlockResult {
- const blockElement = resolveTargetElement(
+ const startElement = resolveTargetElement(
overlayContext.editorDocument,
start
);
+ const endElement = resolveTargetElement(
+ overlayContext.editorDocument,
+ end
+ );
if (
- ! blockElement ||
+ ! startElement ||
+ ! endElement ||
start.richTextOffset === null ||
end.richTextOffset === null
) {
- return { rects: [], blockElement: null };
+ return { rects: [], startElement: null, endElement: null };
+ }
+
+ if ( startElement === endElement ) {
+ return {
+ rects:
+ getSelectionRects(
+ startElement,
+ start.richTextOffset,
+ end.richTextOffset,
+ overlayContext.editorDocument,
+ overlayContext.overlayRect
+ ) ?? [],
+ startElement,
+ endElement,
+ };
+ }
+
+ const startIsAfterEnd = isNodeBefore( endElement, startElement );
+ const firstElement = startIsAfterEnd ? endElement : startElement;
+ const lastElement = startIsAfterEnd ? startElement : endElement;
+ const firstOffset = startIsAfterEnd
+ ? end.richTextOffset
+ : start.richTextOffset;
+ const lastOffset = startIsAfterEnd
+ ? start.richTextOffset
+ : end.richTextOffset;
+ const allRects: SelectionRect[] = [];
+ const firstRects = getSelectionRects(
+ firstElement,
+ firstOffset,
+ Number.MAX_SAFE_INTEGER,
+ overlayContext.editorDocument,
+ overlayContext.overlayRect
+ );
+ if ( firstRects ) {
+ allRects.push( ...firstRects );
}
+
+ for ( const intermediateElement of getRichTextElementsBetween(
+ firstElement,
+ lastElement
+ ) ) {
+ const intermediateRects = getSelectionRects(
+ intermediateElement,
+ 0,
+ Number.MAX_SAFE_INTEGER,
+ overlayContext.editorDocument,
+ overlayContext.overlayRect
+ );
+ if ( intermediateRects ) {
+ allRects.push( ...intermediateRects );
+ }
+ }
+
+ const lastRects = getSelectionRects(
+ lastElement,
+ 0,
+ lastOffset,
+ overlayContext.editorDocument,
+ overlayContext.overlayRect
+ );
+ if ( lastRects ) {
+ allRects.push( ...lastRects );
+ }
+
return {
- rects:
- getSelectionRects(
- blockElement,
- start.richTextOffset,
- end.richTextOffset,
- overlayContext.editorDocument,
- overlayContext.overlayRect
- ) ?? [],
- blockElement,
+ rects: allRects,
+ startElement,
+ endElement,
};
}
+function getRichTextElementsBetween(
+ firstElement: HTMLElement,
+ lastElement: HTMLElement
+): HTMLElement[] {
+ const blockElement = firstElement.closest( '[data-block]' );
+ if (
+ ! blockElement ||
+ blockElement !== lastElement.closest( '[data-block]' )
+ ) {
+ return [];
+ }
+
+ return Array.from(
+ blockElement.querySelectorAll< HTMLElement >(
+ '[data-wp-block-attribute-key]'
+ )
+ ).filter(
+ ( element ) =>
+ element !== firstElement &&
+ element !== lastElement &&
+ element.closest( '[data-block]' ) === blockElement &&
+ isNodeBefore( firstElement, element ) &&
+ isNodeBefore( element, lastElement )
+ );
+}
+
/**
* Compute selection rects for a selection spanning multiple blocks.
*
diff --git a/packages/editor/src/components/collaborators-overlay/test/compute-selection.ts b/packages/editor/src/components/collaborators-overlay/test/compute-selection.ts
new file mode 100644
index 00000000000000..f05995c693b4a7
--- /dev/null
+++ b/packages/editor/src/components/collaborators-overlay/test/compute-selection.ts
@@ -0,0 +1,228 @@
+/**
+ * Internal dependencies
+ */
+import { computeSelectionVisual } from '../compute-selection';
+import { getCursorPosition, getSelectionRects } from '../cursor-dom-utils';
+
+jest.mock( '@wordpress/core-data', () => ( {
+ SelectionDirection: {
+ Backward: 'backward',
+ Forward: 'forward',
+ },
+ SelectionType: {
+ None: 'none',
+ Cursor: 'cursor',
+ SelectionInOneBlock: 'selection-in-one-block',
+ SelectionInMultipleBlocks: 'selection-in-multiple-blocks',
+ WholeBlock: 'whole-block',
+ },
+} ) );
+
+jest.mock( '../cursor-dom-utils', () => ( {
+ getCursorPosition: jest.fn( () => ( {
+ x: 10,
+ y: 20,
+ height: 30,
+ } ) ),
+ getSelectionRects: jest.fn( () => [] ),
+ getFullBlockSelectionRects: jest.fn( () => [] ),
+ getBlocksBetween: jest.fn( () => [] ),
+ isNodeBefore: jest.fn( () => false ),
+} ) );
+
+const mockGetCursorPosition = getCursorPosition as jest.Mock;
+const mockGetSelectionRects = getSelectionRects as jest.Mock;
+const SelectionType = {
+ Cursor: 'cursor',
+ SelectionInOneBlock: 'selection-in-one-block',
+} as const;
+
+type ResolvedSelection = {
+ richTextOffset: number | null;
+ localClientId: string | null;
+ attributeKey: string | null;
+};
+
+function createOverlayContext( bodyHtml: string ) {
+ document.body.innerHTML = bodyHtml;
+
+ return {
+ editorDocument: document,
+ overlayRect: {
+ left: 0,
+ top: 0,
+ right: 100,
+ bottom: 100,
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ toJSON: () => ( {} ),
+ } as DOMRect,
+ };
+}
+
+describe( 'computeSelectionVisual', () => {
+ beforeAll( () => {
+ Object.defineProperty( globalThis, 'CSS', {
+ configurable: true,
+ value: {
+ escape: ( value: string ) => value,
+ },
+ } );
+ } );
+
+ beforeEach( () => {
+ mockGetCursorPosition.mockClear();
+ mockGetSelectionRects.mockClear();
+ } );
+
+ it( 'anchors cursor selections to the matching nested RichText element', () => {
+ const overlayContext = createOverlayContext(
+ '
' +
+ '
Alpha
' +
+ '
Beta
' +
+ '
'
+ );
+
+ const start: ResolvedSelection = {
+ richTextOffset: 2,
+ localClientId: 'block-1',
+ attributeKey: 'body.0.cells.1.content',
+ };
+
+ computeSelectionVisual(
+ { type: SelectionType.Cursor },
+ start,
+ undefined,
+ overlayContext
+ );
+
+ const targetElement = document.querySelector(
+ '[data-wp-block-attribute-key="body.0.cells.1.content"]'
+ );
+
+ expect( mockGetCursorPosition ).toHaveBeenCalledWith(
+ 2,
+ targetElement,
+ document,
+ overlayContext.overlayRect
+ );
+ } );
+
+ it( 'anchors keyed cursor selections to the block element when it is the RichText element', () => {
+ const overlayContext = createOverlayContext(
+ 'Alpha
'
+ );
+
+ const start: ResolvedSelection = {
+ richTextOffset: 2,
+ localClientId: 'block-1',
+ attributeKey: 'content',
+ };
+
+ computeSelectionVisual(
+ { type: SelectionType.Cursor },
+ start,
+ undefined,
+ overlayContext
+ );
+
+ const targetElement = document.querySelector(
+ '[data-block="block-1"]'
+ );
+
+ expect( mockGetCursorPosition ).toHaveBeenCalledWith(
+ 2,
+ targetElement,
+ document,
+ overlayContext.overlayRect
+ );
+ } );
+
+ it( 'does not fall back to the whole block for a missing keyed RichText target', () => {
+ const overlayContext = createOverlayContext(
+ ''
+ );
+
+ const start: ResolvedSelection = {
+ richTextOffset: 2,
+ localClientId: 'block-1',
+ attributeKey: 'body.1.cells.0.content',
+ };
+
+ const result = computeSelectionVisual(
+ { type: SelectionType.Cursor },
+ start,
+ undefined,
+ overlayContext
+ );
+
+ expect( result.coords ).toBeUndefined();
+ expect( mockGetCursorPosition ).not.toHaveBeenCalled();
+ } );
+
+ it( 'renders a same-block selection across different nested RichText elements', () => {
+ const overlayContext = createOverlayContext(
+ '' +
+ '
Beta start text
' +
+ '
Delta end text
' +
+ '
'
+ );
+ const startElement = document.querySelector(
+ '[data-wp-block-attribute-key="body.0.cells.1.content"]'
+ );
+ const endElement = document.querySelector(
+ '[data-wp-block-attribute-key="body.1.cells.1.content"]'
+ );
+ const startRect = { x: 10, y: 10, width: 30, height: 12 };
+ const endRect = { x: 10, y: 40, width: 30, height: 12 };
+ mockGetSelectionRects
+ .mockReturnValueOnce( [ startRect ] )
+ .mockReturnValueOnce( [ endRect ] );
+
+ const start: ResolvedSelection = {
+ richTextOffset: 4,
+ localClientId: 'block-1',
+ attributeKey: 'body.0.cells.1.content',
+ };
+ const end: ResolvedSelection = {
+ richTextOffset: 8,
+ localClientId: 'block-1',
+ attributeKey: 'body.1.cells.1.content',
+ };
+
+ const result = computeSelectionVisual(
+ { type: SelectionType.SelectionInOneBlock },
+ start,
+ end,
+ overlayContext
+ );
+
+ expect( mockGetSelectionRects ).toHaveBeenNthCalledWith(
+ 1,
+ startElement,
+ 4,
+ Number.MAX_SAFE_INTEGER,
+ document,
+ overlayContext.overlayRect
+ );
+ expect( mockGetSelectionRects ).toHaveBeenNthCalledWith(
+ 2,
+ endElement,
+ 0,
+ 8,
+ document,
+ overlayContext.overlayRect
+ );
+ expect( mockGetCursorPosition ).toHaveBeenCalledWith(
+ 8,
+ endElement,
+ document,
+ overlayContext.overlayRect
+ );
+ expect( result.selectionRects ).toEqual( [ startRect, endRect ] );
+ } );
+} );
diff --git a/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts b/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts
index 2ead9c2e10b74d..0d5bfd231b90e4 100644
--- a/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts
+++ b/test/e2e/specs/editor/collaboration/collaboration-nested-awareness-selection.spec.ts
@@ -2,6 +2,252 @@
* Internal dependencies
*/
import { test, expect } from './fixtures';
+import type CollaborationUtils from './fixtures/collaboration-utils';
+
+type Editor = import('@wordpress/e2e-test-utils-playwright').Editor;
+type Page = import('@playwright/test').Page;
+
+type Box = { x: number; y: number; width: number; height: number };
+
+const TWO_ROW_TABLE_CONTENT =
+ '\n' +
+ '' +
+ '| Alpha | Beta |
' +
+ '| Gamma | Delta |
' +
+ '
\n' +
+ '';
+
+const TABLE_WITH_CAPTION_CONTENT =
+ '\n' +
+ 'Caption target\n' +
+ '';
+
+async function expectTableBlockLoaded(
+ collaborationUtils: CollaborationUtils
+) {
+ await expect
+ .poll( () => collaborationUtils.editor2.getBlocks(), {
+ timeout: 10000,
+ } )
+ .toMatchObject( [
+ {
+ name: 'core/table',
+ },
+ ] );
+}
+
+async function getBodyCellTexts( editor: Editor ) {
+ return editor.canvas
+ .getByRole( 'textbox', { name: 'Body cell text' } )
+ .evaluateAll( ( cells ) =>
+ cells.map( ( cell ) => cell.textContent?.trim() )
+ );
+}
+
+function getBodyCellAttributeKey( index: number ) {
+ return `body.${ Math.floor( index / 2 ) }.cells.${ index % 2 }.content`;
+}
+
+function getBodyCellRichText( editor: Editor, index: number ) {
+ return editor.canvas.locator(
+ `[data-wp-block-attribute-key="${ getBodyCellAttributeKey( index ) }"]`
+ );
+}
+
+function boxesIntersect( a: Box, b: Box ) {
+ return (
+ a.x < b.x + b.width &&
+ a.x + a.width > b.x &&
+ a.y < b.y + b.height &&
+ a.y + a.height > b.y
+ );
+}
+
+async function placeCursorAtEndOfCell( {
+ editor,
+ page,
+ index,
+}: {
+ editor: Editor;
+ page: Page;
+ index: number;
+} ) {
+ const cell = getBodyCellRichText( editor, index );
+
+ await cell.click();
+ await page.keyboard.press( 'End' );
+}
+
+async function deleteTableRow( {
+ editor,
+ page,
+ index,
+}: {
+ editor: Editor;
+ page: Page;
+ index: number;
+} ) {
+ await editor.canvas
+ .getByRole( 'textbox', { name: 'Body cell text' } )
+ .nth( index )
+ .click();
+ await editor.clickBlockToolbarButton( 'Edit table' );
+ await page.getByRole( 'menuitem', { name: 'Delete row' } ).click();
+}
+
+async function insertTableRowBefore( {
+ editor,
+ page,
+ index,
+}: {
+ editor: Editor;
+ page: Page;
+ index: number;
+} ) {
+ await editor.canvas
+ .getByRole( 'textbox', { name: 'Body cell text' } )
+ .nth( index )
+ .click();
+ await editor.clickBlockToolbarButton( 'Edit table' );
+ await page.getByRole( 'menuitem', { name: 'Insert row before' } ).click();
+}
+
+async function expectRemoteCursorInsideCell( page: Page, cellIndex: number ) {
+ const editorFrame = page.frameLocator( 'iframe[name="editor-canvas"]' );
+ const cursor = editorFrame.locator( '.collaborators-overlay-user-cursor' );
+
+ await expect
+ .poll( () => cursor.count(), { timeout: 15000 } )
+ .toBeGreaterThan( 0 );
+
+ const cursorBox = await cursor.first().boundingBox();
+ if ( ! cursorBox ) {
+ throw new Error( 'Collaborator cursor bounding box not available' );
+ }
+ expect( cursorBox.height ).toBeGreaterThan( 0 );
+
+ const remoteCell = editorFrame
+ .locator( 'role=textbox[name="Body cell text"i]' )
+ .nth( cellIndex );
+ const cellBox = await remoteCell.boundingBox();
+ if ( ! cellBox ) {
+ throw new Error( 'Remote target cell bounding box not available' );
+ }
+
+ const cursorCenterX = cursorBox.x + cursorBox.width / 2;
+ const cursorCenterY = cursorBox.y + cursorBox.height / 2;
+ const tolerance = 4;
+
+ expect( cursorCenterX ).toBeGreaterThanOrEqual( cellBox.x - tolerance );
+ expect( cursorCenterX ).toBeLessThanOrEqual(
+ cellBox.x + cellBox.width + tolerance
+ );
+ expect( cursorCenterY ).toBeGreaterThanOrEqual( cellBox.y - tolerance );
+ expect( cursorCenterY ).toBeLessThanOrEqual(
+ cellBox.y + cellBox.height + tolerance
+ );
+}
+
+async function getSelectionAttributeKeys( page: Page ) {
+ return page.evaluate( () => {
+ const blockEditor = window.wp.data.select( 'core/block-editor' );
+ return {
+ start: blockEditor.getSelectionStart()?.attributeKey ?? null,
+ end: blockEditor.getSelectionEnd()?.attributeKey ?? null,
+ };
+ } );
+}
+
+async function selectCellTextInStore( {
+ page,
+ index,
+}: {
+ page: Page;
+ index: number;
+} ) {
+ await page.evaluate(
+ ( { attributeKey, rowIndex, cellIndex } ) => {
+ const blocks = window.wp.data
+ .select( 'core/block-editor' )
+ .getBlocks() as Array< {
+ name: string;
+ clientId: string;
+ attributes: {
+ body: Array< {
+ cells: Array< { content: string } >;
+ } >;
+ };
+ } >;
+ const tableBlock = blocks.find(
+ ( block ) => block.name === 'core/table'
+ );
+
+ if ( ! tableBlock ) {
+ throw new Error( 'Could not resolve table block' );
+ }
+
+ window.wp.data.dispatch( 'core/block-editor' ).selectionChange( {
+ start: {
+ clientId: tableBlock.clientId,
+ attributeKey,
+ offset: 0,
+ },
+ end: {
+ clientId: tableBlock.clientId,
+ attributeKey,
+ offset: tableBlock.attributes.body[ rowIndex ].cells[
+ cellIndex
+ ].content.length,
+ },
+ } );
+ },
+ {
+ attributeKey: getBodyCellAttributeKey( index ),
+ rowIndex: Math.floor( index / 2 ),
+ cellIndex: index % 2,
+ }
+ );
+}
+
+async function expectRemoteSelectionInsideCells(
+ page: Page,
+ cellIndices: number[]
+) {
+ const editorFrame = page.frameLocator( 'iframe[name="editor-canvas"]' );
+ const selectionRects = editorFrame.locator(
+ '.collaborators-overlay-selection-rect'
+ );
+
+ await expect
+ .poll( () => selectionRects.count(), { timeout: 15000 } )
+ .toBeGreaterThanOrEqual( cellIndices.length );
+
+ const rectBoxes: Box[] = [];
+ const rectCount = await selectionRects.count();
+ for ( let i = 0; i < rectCount; i++ ) {
+ const rectBox = await selectionRects.nth( i ).boundingBox();
+ if ( rectBox && rectBox.width > 0 && rectBox.height > 0 ) {
+ rectBoxes.push( rectBox );
+ }
+ }
+
+ for ( const cellIndex of cellIndices ) {
+ const cellBox = await editorFrame
+ .locator( 'role=textbox[name="Body cell text"i]' )
+ .nth( cellIndex )
+ .boundingBox();
+
+ if ( ! cellBox ) {
+ throw new Error( 'Remote target cell bounding box not available' );
+ }
+
+ expect(
+ rectBoxes.some( ( rectBox ) => boxesIntersect( rectBox, cellBox ) )
+ ).toBe( true );
+ }
+}
test.describe( 'Collaboration - Nested Awareness Selection', () => {
test( 'cursor in a table cell appears in the same cell for another user', async ( {
@@ -14,28 +260,14 @@ test.describe( 'Collaboration - Nested Awareness Selection', () => {
title: 'Nested Awareness Selection Test',
status: 'draft',
date_gmt: new Date().toISOString(),
- content:
- '\n' +
- '' +
- '| Alpha | Beta |
' +
- '| Gamma | Delta |
' +
- '
\n' +
- '',
+ content: TWO_ROW_TABLE_CONTENT,
} );
await collaborationUtils.openCollaborativeSession( post.id );
const { page2 } = collaborationUtils;
- await expect
- .poll( () => collaborationUtils.editor2.getBlocks(), {
- timeout: 10000,
- } )
- .toMatchObject( [
- {
- name: 'core/table',
- },
- ] );
+ await expectTableBlockLoaded( collaborationUtils );
// Target the last cell — row 1, column 1 ("Delta"), which is nth=3
// in a 2x2 grid. Picking the last cell maximizes distance from the
@@ -102,4 +334,185 @@ test.describe( 'Collaboration - Nested Awareness Selection', () => {
cellBox.y + cellBox.height
);
} );
+
+ test( 'cursor follows a table cell after another user deletes a preceding row', async ( {
+ collaborationUtils,
+ requestUtils,
+ editor,
+ page,
+ } ) => {
+ const post = await requestUtils.createPost( {
+ title: 'Nested Awareness Selection Row Delete Test',
+ status: 'draft',
+ date_gmt: new Date().toISOString(),
+ content: TWO_ROW_TABLE_CONTENT,
+ } );
+
+ await collaborationUtils.openCollaborativeSession( post.id );
+
+ const { editor2, page2 } = collaborationUtils;
+
+ await expectTableBlockLoaded( collaborationUtils );
+
+ await placeCursorAtEndOfCell( { editor, page, index: 3 } );
+
+ await expect
+ .poll(
+ () =>
+ page.evaluate(
+ () =>
+ window.wp.data
+ .select( 'core/block-editor' )
+ .getSelectionStart()?.attributeKey ?? ''
+ ),
+ { timeout: 5000 }
+ )
+ .toBe( 'body.1.cells.1.content' );
+
+ await expectRemoteCursorInsideCell( page2, 3 );
+
+ await deleteTableRow( { editor: editor2, page: page2, index: 0 } );
+
+ await collaborationUtils.waitForConvergence( { timeout: 15000 } );
+
+ await expect
+ .poll( () => getBodyCellTexts( editor2 ), { timeout: 10000 } )
+ .toEqual( [ 'Gamma', 'Delta' ] );
+
+ await expectRemoteCursorInsideCell( page2, 1 );
+ } );
+
+ test( 'cursor follows a table cell after another user inserts a preceding row', async ( {
+ collaborationUtils,
+ requestUtils,
+ editor,
+ page,
+ } ) => {
+ const post = await requestUtils.createPost( {
+ title: 'Nested Awareness Selection Row Insert Test',
+ status: 'draft',
+ date_gmt: new Date().toISOString(),
+ content: TWO_ROW_TABLE_CONTENT,
+ } );
+
+ await collaborationUtils.openCollaborativeSession( post.id );
+
+ const { editor2, page2 } = collaborationUtils;
+
+ await expectTableBlockLoaded( collaborationUtils );
+
+ await placeCursorAtEndOfCell( { editor, page, index: 3 } );
+
+ await expect
+ .poll( () => getSelectionAttributeKeys( page ), { timeout: 5000 } )
+ .toMatchObject( {
+ start: 'body.1.cells.1.content',
+ end: 'body.1.cells.1.content',
+ } );
+
+ await expectRemoteCursorInsideCell( page2, 3 );
+
+ await insertTableRowBefore( {
+ editor: editor2,
+ page: page2,
+ index: 3,
+ } );
+
+ await collaborationUtils.waitForConvergence( { timeout: 15000 } );
+
+ await expect
+ .poll( () => getBodyCellTexts( editor2 ), { timeout: 10000 } )
+ .toEqual( [ 'Alpha', 'Beta', '', '', 'Gamma', 'Delta' ] );
+
+ await expectRemoteCursorInsideCell( page2, 5 );
+ } );
+
+ test( 'selection in a table cell renders in a nested RichText field', async ( {
+ collaborationUtils,
+ requestUtils,
+ page,
+ } ) => {
+ const post = await requestUtils.createPost( {
+ title: 'Nested Awareness Selection Cross Cell Selection Test',
+ status: 'draft',
+ date_gmt: new Date().toISOString(),
+ content: TWO_ROW_TABLE_CONTENT,
+ } );
+
+ await collaborationUtils.openCollaborativeSession( post.id );
+
+ await expectTableBlockLoaded( collaborationUtils );
+
+ await selectCellTextInStore( {
+ page,
+ index: 1,
+ } );
+
+ await expect
+ .poll( () => getSelectionAttributeKeys( page ), { timeout: 5000 } )
+ .toMatchObject( {
+ start: 'body.0.cells.1.content',
+ end: 'body.0.cells.1.content',
+ } );
+
+ await expectRemoteSelectionInsideCells( collaborationUtils.page2, [
+ 1,
+ ] );
+ } );
+
+ test( 'cursor in a table caption disappears when another user removes the caption', async ( {
+ collaborationUtils,
+ requestUtils,
+ editor,
+ page,
+ } ) => {
+ const post = await requestUtils.createPost( {
+ title: 'Nested Awareness Selection Caption Delete Test',
+ status: 'draft',
+ date_gmt: new Date().toISOString(),
+ content: TABLE_WITH_CAPTION_CONTENT,
+ } );
+
+ await collaborationUtils.openCollaborativeSession( post.id );
+
+ const { editor2, page2 } = collaborationUtils;
+
+ await expectTableBlockLoaded( collaborationUtils );
+
+ await editor.canvas
+ .getByRole( 'textbox', { name: 'Table caption text' } )
+ .click();
+ await page.keyboard.press( 'End' );
+
+ await expect
+ .poll( () => getSelectionAttributeKeys( page ), { timeout: 5000 } )
+ .toMatchObject( {
+ start: 'caption',
+ end: 'caption',
+ } );
+
+ const editorFrame = page2.frameLocator(
+ 'iframe[name="editor-canvas"]'
+ );
+ const cursor = editorFrame.locator(
+ '.collaborators-overlay-user-cursor'
+ );
+
+ await expect
+ .poll( () => cursor.count(), { timeout: 15000 } )
+ .toBeGreaterThan( 0 );
+
+ await editor2.canvas
+ .getByRole( 'textbox', { name: 'Table caption text' } )
+ .click();
+ await editor2.clickBlockToolbarButton( 'Remove caption' );
+
+ await expect(
+ editor2.canvas.getByRole( 'textbox', {
+ name: 'Table caption text',
+ } )
+ ).toHaveCount( 0 );
+
+ await expect.poll( () => cursor.count(), { timeout: 10000 } ).toBe( 0 );
+ } );
} );