Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/block-library/src/table/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ const Cell = memo( function ( {
) }
>
<RichText
identifier={ `${ name }.${ rowIndex }.cells.${ columnIndex }.content` }
value={ content }
onChange={ onChange }
onFocus={ () => {
Expand Down
34 changes: 34 additions & 0 deletions packages/core-data/src/awareness/block-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,40 @@ interface EditorStoreBlock {
innerBlocks: EditorStoreBlock[];
}

/**
* Find the block Y.Map that contains a nested Yjs type.
*
* Rich-text attributes are often stored directly at attributes.content, but
* blocks can also store rich text deeper inside object or array attributes.
* Walk upward until we find the block map instead of assuming a fixed parent
* depth.
*
* @param yType - The nested Yjs type to start from.
* @return The containing block Y.Map, or null if no block ancestor exists.
*/
export function getContainingBlockYMap(
yType: Y.AbstractType< any >
): Y.Map< unknown > | null {
let current: Y.AbstractType< any > | null = yType;

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
) {
return parent;
}

current = parent instanceof Y.AbstractType ? parent : null;
}

return null;
}

/**
* Given a Y.Map within a Ydoc, traverse up the Yjs block tree to compute the
* index path from the root.
Expand Down
44 changes: 31 additions & 13 deletions packages/core-data/src/awareness/post-editor-awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
* Internal dependencies
*/
import { BaseAwarenessState, baseEqualityFieldChecks } from './base-awareness';
import { getBlockPathInYdoc, resolveBlockClientIdByPath } from './block-lookup';
import {
getBlockPathInYdoc,
getContainingBlockYMap,
resolveBlockClientIdByPath,
} from './block-lookup';
import {
AWARENESS_CURSOR_UPDATE_THROTTLE_IN_MS,
LOCAL_CURSOR_UPDATE_DEBOUNCE_IN_MS,
Expand All @@ -27,7 +31,11 @@ import {
} from '../utils/crdt-user-selections';

import { SelectionDirection } from '../types';
import type { SelectionState, WPBlockSelection } from '../types';
import type {
ResolvedSelection,
SelectionState,
WPBlockSelection,
} from '../types';
import type { YBlocks } from '../utils/crdt-blocks';
import type {
DebugCollaboratorData,
Expand Down Expand Up @@ -239,12 +247,15 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
* @param selection - The selection state.
* @return The rich-text offset and block client ID, or nulls if not resolvable.
*/
public convertSelectionStateToAbsolute( selection: SelectionState ): {
richTextOffset: number | null;
localClientId: string | null;
} {
public convertSelectionStateToAbsolute(
selection: SelectionState
): ResolvedSelection {
if ( selection.type === SelectionType.None ) {
return { richTextOffset: null, localClientId: null };
return {
richTextOffset: null,
localClientId: null,
attributeKey: null,
};
}

if ( selection.type === SelectionType.WholeBlock ) {
Expand All @@ -267,7 +278,11 @@ 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.
Expand All @@ -282,13 +297,15 @@ 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;
const yType = getContainingBlockYMap( absolutePosition.type );
const path = yType ? getBlockPathInYdoc( yType ) : null;
const localClientId = path ? resolveBlockClientIdByPath( path ) : null;

return {
Expand All @@ -297,6 +314,7 @@ export class PostEditorAwareness extends BaseAwarenessState< PostEditorState > {
asHtmlStringIndex( absolutePosition.index )
),
localClientId,
attributeKey: cursorPos.attributeKey ?? null,
};
}

Expand Down
70 changes: 70 additions & 0 deletions packages/core-data/src/awareness/test/block-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { select } from '@wordpress/data';
*/
import {
getBlockPathInYdoc,
getContainingBlockYMap,
resolveBlockClientIdByPath,
} from '../block-lookup';

Expand Down Expand Up @@ -242,6 +243,75 @@ describe( 'getBlockPathInYdoc', () => {
} );
} );

describe( 'getContainingBlockYMap', () => {
it( 'should find the containing block for direct rich text content', () => {
const block = createTestYBlock( 'block' );
const attributes = new Y.Map< any >();
const text = new Y.Text( 'Direct text' );
attributes.set( 'content', text );
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 );
} );

it( 'should find the containing block for deeply nested rich text attributes', () => {
const block = createTestYBlock( 'block' );
const attributes = new Y.Map< any >();
const cards = new Y.Array< Y.Map< any > >();
const card = new Y.Map< any >();
const meta = new Y.Map< any >();
const caption = new Y.Text( 'Nested caption' );

meta.set( 'caption', caption );
card.set( 'meta', meta );
cards.push( [ card ] );
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( caption ) ).toBe( block );
} );

it( 'should return null when no block ancestor exists', () => {
const orphanAttributes = new Y.Map< any >();
const text = new Y.Text( 'Orphan text' );
orphanAttributes.set( 'content', text );

expect( getContainingBlockYMap( text ) ).toBeNull();
} );

it( 'should skip nested attribute maps that look like blocks', () => {
const block = createTestYBlock( 'block' );
const attributes = new Y.Map< any >();
const blockLikeAttribute = new Y.Map< any >();
const text = new Y.Text( 'Nested text' );
blockLikeAttribute.set( 'clientId', 'attribute-client-id' );
blockLikeAttribute.set( 'innerBlocks', new Y.Array() );
blockLikeAttribute.set( 'content', text );
attributes.set( 'nested', blockLikeAttribute );
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', () => {
afterEach( () => {
jest.restoreAllMocks();
Expand Down
Loading
Loading