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( + '
' + + '
Alpha
' + + '
' + ); + + 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' + + '
' + + '' + + '' + + '
AlphaBeta
GammaDelta
\n' + + ''; + +const TABLE_WITH_CAPTION_CONTENT = + '\n' + + '
' + + '' + + '
AlphaBeta
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' + - '
' + - '' + - '' + - '
AlphaBeta
GammaDelta
\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 ); + } ); } );