Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 17 additions & 14 deletions packages/core-data/src/awareness/block-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

/**
Expand Down
34 changes: 33 additions & 1 deletion packages/core-data/src/awareness/post-editor-awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
import { STORE_NAME as coreStore } from '../name';
import {
asHtmlStringIndex,
getAttributeKeyForYText,
getYTextByAttributeKey,
htmlIndexToRichTextOffset,
} from '../utils/crdt-utils';
import {
Expand Down Expand Up @@ -307,14 +309,44 @@ 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(
absolutePosition.type.toString(),
asHtmlStringIndex( absolutePosition.index )
),
localClientId,
attributeKey: cursorPos.attributeKey ?? null,
attributeKey,
};
}

Expand Down
22 changes: 22 additions & 0 deletions packages/core-data/src/awareness/test/block-lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
72 changes: 70 additions & 2 deletions packages/core-data/src/awareness/test/post-editor-awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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' );
} );
} );

Expand Down Expand Up @@ -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)', () => {
Expand Down
14 changes: 12 additions & 2 deletions packages/core-data/src/utils/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -874,7 +874,7 @@ function mergeYMapValues(
yMap,
key,
cursorPosition,
cursorScope
appendCursorScopeKey( cursorScope, key )
);
}

Expand Down Expand Up @@ -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 ];
}
Expand Down
23 changes: 21 additions & 2 deletions packages/core-data/src/utils/crdt-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
import {
asHtmlStringIndex,
findBlockByClientIdInDoc,
getAttributeKeyForYText,
getYTextByAttributeKey,
htmlIndexToRichTextOffset,
} from './crdt-utils';
import type { WPBlockSelection, WPSelection } from '../types';
Expand Down Expand Up @@ -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 )
Expand Down
6 changes: 5 additions & 1 deletion packages/core-data/src/utils/crdt-user-selections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Loading
Loading