From a2cc3a890abe885f54cf30d6a888290568b34b41 Mon Sep 17 00:00:00 2001 From: karthikeya-io <82776409+karthikeya-io@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:20:26 +0530 Subject: [PATCH 1/3] RTC: Fix collaborator cursor rendering in table cells --- packages/block-library/src/table/edit.js | 1 + .../src/awareness/post-editor-awareness.ts | 68 ++++++++++++++++--- packages/core-data/src/types.ts | 1 + .../src/utils/crdt-user-selections.ts | 55 ++++++++++++++- .../compute-selection.ts | 18 +++-- .../collaborators-overlay/cursor-dom-utils.ts | 47 +++++++++---- 6 files changed, 162 insertions(+), 28 deletions(-) diff --git a/packages/block-library/src/table/edit.js b/packages/block-library/src/table/edit.js index 9f649b2901e40a..9b192f757b82b0 100644 --- a/packages/block-library/src/table/edit.js +++ b/packages/block-library/src/table/edit.js @@ -631,6 +631,7 @@ const Cell = memo( function ( { ) } > { diff --git a/packages/core-data/src/awareness/post-editor-awareness.ts b/packages/core-data/src/awareness/post-editor-awareness.ts index df37786e36f67c..b4decdd1bfd1ad 100644 --- a/packages/core-data/src/awareness/post-editor-awareness.ts +++ b/packages/core-data/src/awareness/post-editor-awareness.ts @@ -224,7 +224,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { * Resolve a selection state to a text index and block client ID. * * For text-based selections, navigates up from the resolved Y.Text via - * AbstractType.parent to find the containing block, then resolves the + * parent chain to find the containing block, then resolves the * local clientId via the block's tree path. * For WholeBlock selections, resolves the block's relative position and * then finds the local clientId via tree path. @@ -234,14 +234,19 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { * clientIds (e.g. in "Show Template" mode where blocks are cloned). * * @param selection - The selection state. - * @return The rich-text offset and block client ID, or nulls if not resolvable. + * @return The rich-text offset, block client ID, and attribute key path. */ public convertSelectionStateToAbsolute( selection: SelectionState ): { richTextOffset: number | null; localClientId: string | null; + attributeKey: string | null; } { if ( selection.type === SelectionType.None ) { - return { richTextOffset: null, localClientId: null }; + return { + richTextOffset: null, + localClientId: null, + attributeKey: null, + }; } if ( selection.type === SelectionType.WholeBlock ) { @@ -264,7 +269,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { } } - return { richTextOffset: null, localClientId }; + return { richTextOffset: null, localClientId, attributeKey: null }; } // Text-based selections: resolve cursor position and navigate up. @@ -279,13 +284,57 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { ); if ( ! absolutePosition ) { - return { richTextOffset: null, localClientId: null }; + return { + richTextOffset: null, + localClientId: null, + attributeKey: null, + }; } - // Navigate up: Y.Text -> attributes Y.Map -> block Y.Map - const yType = absolutePosition.type.parent?.parent; - const path = - yType instanceof Y.Map ? getBlockPathInYdoc( yType ) : null; + // Navigate up from Y.Text to find the block and its attribute path. + let current: Y.AbstractType< any > | null = absolutePosition.type; + let blockMap: Y.Map< unknown > | null = null; + const pathParts: string[] = []; + let pendingIndex: number | null = null; + + while ( current && current.parent ) { + const parent = current.parent; + if ( parent instanceof Y.Map ) { + let foundKey: string | null = null; + for ( const key of parent.keys() ) { + if ( parent.get( key ) === current ) { + foundKey = key; + break; + } + } + + if ( foundKey ) { + if ( + foundKey === 'attributes' && + parent.has( 'clientId' ) + ) { + blockMap = parent; + break; + } + let part = foundKey; + if ( pendingIndex !== null ) { + part += `[${ pendingIndex }]`; + pendingIndex = null; + } + pathParts.unshift( part ); + } + } else if ( parent instanceof Y.Array ) { + for ( let i = 0; i < parent.length; i++ ) { + if ( parent.get( i ) === current ) { + pendingIndex = i; + break; + } + } + } + current = parent; + } + + const path = blockMap ? getBlockPathInYdoc( blockMap ) : null; const localClientId = path ? resolveBlockClientIdByPath( path ) : null; return { @@ -294,6 +343,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > { absolutePosition.index ), localClientId, + attributeKey: pathParts.join( '.' ) || null, }; } diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index bca5f25eef31dc..76ef5e67d89ddc 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -135,4 +135,5 @@ export type SelectionState = export interface ResolvedSelection { richTextOffset: number | null; localClientId: string | null; + attributeKey: string | null; } diff --git a/packages/core-data/src/utils/crdt-user-selections.ts b/packages/core-data/src/utils/crdt-user-selections.ts index dce99fb9ab9c44..359da3dbea1f38 100644 --- a/packages/core-data/src/utils/crdt-user-selections.ts +++ b/packages/core-data/src/utils/crdt-user-selections.ts @@ -147,6 +147,52 @@ export function getSelectionState( }; } +/** + * Navigate a Yjs type hierarchy by a string path, supporting both Map keys + * and Array indices. + * + * Example: "body[0].cells[0].content" + * + * @param root - The starting Yjs type. + * @param path - The string path to navigate. + * @return The Yjs type at the path, or undefined if not found. + */ +function getYjsValueByPath( + root: Y.AbstractType< any >, + path: string +): Y.AbstractType< any > | undefined { + const parts = path.split( '.' ); + let current: any = root; + + for ( const part of parts ) { + // Handle array access like "body[0]" + const arrayMatch = part.match( /^(.+)\[(\d+)\]$/ ); + if ( arrayMatch ) { + const [ , key, index ] = arrayMatch; + if ( ! ( current instanceof Y.Map ) ) { + return undefined; + } + current = current.get( key ); + if ( ! ( current instanceof Y.Array ) ) { + return undefined; + } + current = current.get( parseInt( index, 10 ) ); + } else { + // Handle simple Map key + if ( ! ( current instanceof Y.Map ) ) { + return undefined; + } + current = current.get( part ); + } + + if ( ! current ) { + return undefined; + } + } + + return current instanceof Y.AbstractType ? current : undefined; +} + /** * Get the cursor position from a selection. * @@ -169,7 +215,14 @@ function getCursorPosition( } const attributes = block.get( 'attributes' ); - const currentYText = attributes?.get( selection.attributeKey ); + if ( ! attributes ) { + return null; + } + + const currentYText = getYjsValueByPath( + attributes, + selection.attributeKey + ); // If the attribute is not a Y.Text, return null. if ( ! ( currentYText instanceof Y.Text ) ) { diff --git a/packages/editor/src/components/collaborators-overlay/compute-selection.ts b/packages/editor/src/components/collaborators-overlay/compute-selection.ts index f0958a85c5ab72..bad91374bbad3d 100644 --- a/packages/editor/src/components/collaborators-overlay/compute-selection.ts +++ b/packages/editor/src/components/collaborators-overlay/compute-selection.ts @@ -92,7 +92,8 @@ function computeCursorOnly( start.richTextOffset, blockElement, overlayContext.editorDocument, - overlayContext.overlayRect + overlayContext.overlayRect, + start.attributeKey ), }; } @@ -150,7 +151,8 @@ function computeTextSelection( activeEnd.richTextOffset, activeEndBlock, overlayContext.editorDocument, - overlayContext.overlayRect + overlayContext.overlayRect, + activeEnd.attributeKey ), selectionRects: allRects, }; @@ -167,7 +169,8 @@ function computeTextSelection( start.richTextOffset, startBlock, overlayContext.editorDocument, - overlayContext.overlayRect + overlayContext.overlayRect, + start.attributeKey ), }; } @@ -203,7 +206,8 @@ function computeSingleBlockRects( start.richTextOffset, end.richTextOffset, overlayContext.editorDocument, - overlayContext.overlayRect + overlayContext.overlayRect, + start.attributeKey ) ?? [], blockElement, }; @@ -265,7 +269,8 @@ function computeMultiBlockRects( docFirst.richTextOffset, Number.MAX_SAFE_INTEGER, overlayContext.editorDocument, - overlayContext.overlayRect + overlayContext.overlayRect, + docFirst.attributeKey ); if ( startRects ) { allRects.push( ...startRects ); @@ -292,7 +297,8 @@ function computeMultiBlockRects( 0, docLast.richTextOffset, overlayContext.editorDocument, - overlayContext.overlayRect + overlayContext.overlayRect, + docLast.attributeKey ); if ( endRects ) { allRects.push( ...endRects ); diff --git a/packages/editor/src/components/collaborators-overlay/cursor-dom-utils.ts b/packages/editor/src/components/collaborators-overlay/cursor-dom-utils.ts index 59d8e49c3dadfc..909f66a313adf2 100644 --- a/packages/editor/src/components/collaborators-overlay/cursor-dom-utils.ts +++ b/packages/editor/src/components/collaborators-overlay/cursor-dom-utils.ts @@ -20,13 +20,15 @@ const MAX_NODE_OFFSET_COUNT = 500; * @param blockElement - The block element (or null if deleted) * @param editorDocument - The editor document * @param overlayRect - Pre-computed bounding rect of the overlay element + * @param attributeKey - Optional attribute key to find a specific RichText area * @return The position of the cursor */ export const getCursorPosition = ( absolutePositionIndex: number | null, blockElement: HTMLElement | null, editorDocument: Document, - overlayRect: DOMRect + overlayRect: DOMRect, + attributeKey: string | null = null ): CursorCoords | null => { if ( absolutePositionIndex === null || ! blockElement ) { return null; @@ -37,7 +39,8 @@ export const getCursorPosition = ( blockElement, absolutePositionIndex, editorDocument, - overlayRect + overlayRect, + attributeKey ) ?? null ); }; @@ -49,18 +52,21 @@ export const getCursorPosition = ( * @param charOffset - The character offset * @param editorDocument - The editor document * @param overlayRect - Pre-computed bounding rect of the overlay element + * @param attributeKey - Optional attribute key to find a specific RichText area * @return The position of the cursor */ const getOffsetPositionInBlock = ( blockElement: HTMLElement, charOffset: number, editorDocument: Document, - overlayRect: DOMRect + overlayRect: DOMRect, + attributeKey: string | null = null ) => { const { node, offset } = findInnerBlockOffset( blockElement, charOffset, - editorDocument + editorDocument, + attributeKey ); const cursorRange = editorDocument.createRange(); @@ -117,6 +123,7 @@ const getOffsetPositionInBlock = ( * @param endOffset - End character offset within the block * @param editorDocument - The editor document * @param overlayRect - Pre-computed bounding rect of the overlay element + * @param attributeKey - Optional attribute key to find a specific RichText area * @return Array of selection rectangles relative to the overlay, or null on failure */ export const getSelectionRects = ( @@ -124,7 +131,8 @@ export const getSelectionRects = ( startOffset: number, endOffset: number, editorDocument: Document, - overlayRect: DOMRect + overlayRect: DOMRect, + attributeKey: string | null = null ): SelectionRect[] | null => { // Normalize direction. let normalizedStart = startOffset; @@ -136,12 +144,14 @@ export const getSelectionRects = ( const startPos = findInnerBlockOffset( blockElement, normalizedStart, - editorDocument + editorDocument, + attributeKey ); const endPos = findInnerBlockOffset( blockElement, normalizedEnd, - editorDocument + editorDocument, + attributeKey ); const range = editorDocument.createRange(); @@ -284,15 +294,28 @@ export const getBlocksBetween = ( * @param blockElement - The block element * @param offset - The character offset * @param editorDocument - The editor document + * @param attributeKey - Optional attribute key to find a specific RichText area * @return The node and offset of the character at the offset */ export const findInnerBlockOffset = ( blockElement: HTMLElement, offset: number, - editorDocument: Document + editorDocument: Document, + attributeKey: string | null = null ) => { + let root: HTMLElement = blockElement; + + if ( attributeKey ) { + const richTextElement = blockElement.querySelector< HTMLElement >( + `[data-wp-block-attribute-key="${ attributeKey }"]` + ); + if ( richTextElement ) { + root = richTextElement; + } + } + const treeWalker = editorDocument.createTreeWalker( - blockElement, + root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT // eslint-disable-line no-bitwise ); @@ -310,7 +333,7 @@ export const findInnerBlockOffset = ( if ( lastTextNode ) { return { node: lastTextNode, offset: 0 }; } - return { node: blockElement, offset: 0 }; + return { node: root, offset: 0 }; } const nodeLength = node.nodeValue?.length ?? 0; @@ -333,7 +356,7 @@ export const findInnerBlockOffset = ( }; } // Just in case, if there's no last text node, return the beginning of the block. - return { node: blockElement, offset: 0 }; + return { node: root, offset: 0 }; } // The
is before the target offset. Count it as a single character. @@ -368,7 +391,7 @@ export const findInnerBlockOffset = ( } // We didn't find any text nodes. Return the beginning of the block. - return { node: blockElement, offset: 0 }; + return { node: root, offset: 0 }; }; /** From e46370fa8106d256e75a949fec6c15391843d17c Mon Sep 17 00:00:00 2001 From: karthikeya-io Date: Tue, 14 Apr 2026 13:28:55 +0530 Subject: [PATCH 2/3] Fix: TypeScript compilation errors for selection state --- packages/core-data/src/hooks/use-post-editor-awareness-state.ts | 1 + .../src/components/collaborators-overlay/use-render-cursors.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts index 19eb10dc863d81..e4b2b7090f5a08 100644 --- a/packages/core-data/src/hooks/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/use-post-editor-awareness-state.ts @@ -27,6 +27,7 @@ interface AwarenessState { const defaultResolvedSelection: ResolvedSelection = { richTextOffset: null, localClientId: null, + attributeKey: null, }; const defaultState: AwarenessState = { diff --git a/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts b/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts index 61005ca094672d..0d77542387c6ad 100644 --- a/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts +++ b/packages/editor/src/components/collaborators-overlay/use-render-cursors.ts @@ -106,6 +106,7 @@ export function useRenderCursors( let start: ResolvedSelection = { richTextOffset: null, localClientId: null, + attributeKey: null, }; let end: ResolvedSelection | undefined; From 5b2690584f638de488b085f20e31af06f71bcccc Mon Sep 17 00:00:00 2001 From: karthikeya-io Date: Tue, 14 Apr 2026 18:41:26 +0530 Subject: [PATCH 3/3] Fix: Update useResolvedSelection unit tests to include attributeKey --- .../src/hooks/test/use-post-editor-awareness-state.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts b/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts index 065f0cefe72b35..c1c49796252684 100644 --- a/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts +++ b/packages/core-data/src/hooks/test/use-post-editor-awareness-state.ts @@ -295,6 +295,7 @@ describe( 'use-post-editor-awareness-state hooks', () => { expect( result.current( mockSelection ) ).toEqual( { richTextOffset: null, localClientId: null, + attributeKey: null, } ); } ); @@ -309,6 +310,7 @@ describe( 'use-post-editor-awareness-state hooks', () => { mockAwareness.convertSelectionStateToAbsolute.mockReturnValue( { richTextOffset: 10, localClientId: 'block-1', + attributeKey: 'content', } ); const { result } = renderHook( () => @@ -323,6 +325,7 @@ describe( 'use-post-editor-awareness-state hooks', () => { expect( position ).toEqual( { richTextOffset: 10, localClientId: 'block-1', + attributeKey: 'content', } ); } ); } );