@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
1,171 lines (1,044 loc) • 34.1 kB
text/typescript
import { v4 as uuidv4 } from 'uuid';
import fastDeepEqual from 'fast-deep-equal/es6/index.js';
import { getBlockTypes } from '@wordpress/blocks';
import { RichTextData } from '@wordpress/rich-text';
import { Y } from '@wordpress/sync';
import {
asRichTextOffset,
createYMap,
richTextOffsetToHtmlIndex,
type HtmlStringIndex,
type YMapRecord,
type YMapWrap,
} from './crdt-utils';
import { getCachedRichTextData } from './crdt-text';
import { Delta } from '../sync';
import { type WPBlockSelection } from '../types';
interface BlockAttributes {
[ key: string ]: unknown;
}
interface BlockAttributeSchema {
role?: string;
type?: string;
query?: Record< string, BlockAttributeSchema >;
}
interface BlockType {
attributes?: Record< string, BlockAttributeSchema >;
name: string;
}
// A block as represented in Gutenberg's data store.
export interface Block {
attributes: BlockAttributes;
clientId?: string;
innerBlocks: Block[];
innerContent?: Array< string | null >;
isValid?: boolean;
name: string;
originalContent?: string;
validationIssues?: string[]; // unserializable
}
// A block as represented in the CRDT document (Y.Map).
export interface YBlockRecord extends YMapRecord {
attributes: YBlockAttributes;
clientId: string;
innerBlocks: YBlocks;
isValid?: boolean;
originalContent?: string;
name: string;
}
export type YBlock = YMapWrap< YBlockRecord >;
export type YBlocks = Y.Array< YBlock >;
// Block attribute schema cannot be known at compile time, so we use Y.Map.
// Attribute values will be typed as the union of `Y.Text` and `unknown`.
export type YBlockAttributes = Y.Map< Y.Text | unknown >;
interface MergeCrdtBlocksOptions {
preserveClientIds?: boolean;
}
/**
* Optional description of where a cursor falls.
*
* Used to coordinate shifting of cursor when applying changes
* to a Y.Doc with RichText instances.
*/
export type MergeCursorPosition = WPBlockSelection | null;
const serializableBlocksCache = new WeakMap< WeakKey, Block[] >();
/**
* Recursively walk an attribute value and convert any RichTextData instances
* to their string (HTML) representation. This is necessary for array-type and
* object-type attributes, which can contain nested RichTextData.
*
* @param value The attribute value to serialize.
* @return The value with all RichTextData instances replaced by strings.
*/
function serializeAttributeValue( value: unknown ): unknown {
if ( value instanceof RichTextData ) {
return value.valueOf();
}
// e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
if ( Array.isArray( value ) ) {
return value.map( serializeAttributeValue );
}
// e.g. a single row inside core/table `body`: { cells: [ ... ] }
if ( value && typeof value === 'object' ) {
const result: Record< string, unknown > = {};
for ( const [ k, v ] of Object.entries( value ) ) {
result[ k ] = serializeAttributeValue( v );
}
return result;
}
return value;
}
function makeBlockAttributesSerializable(
blockName: string,
attributes: BlockAttributes
): BlockAttributes {
const newAttributes = { ...attributes };
for ( const [ key, value ] of Object.entries( attributes ) ) {
if ( isLocalAttribute( blockName, key ) ) {
delete newAttributes[ key ];
continue;
}
newAttributes[ key ] = serializeAttributeValue( value );
}
return newAttributes;
}
/**
* Recursively removes properties which cannot be serialized from a list of block objects.
*
* @param blocks Eemove unserializable properties from each block object in this set.
* @return Copies of the provided blocks without the unserializable properties.
*/
function makeBlocksSerializable( blocks: Block[] ): Block[] {
return blocks.map( ( block: Block ) => {
const {
name,
innerBlocks,
attributes,
/*
* Any validation issues discovered when loading a block are appended
* to the block node with a logging function, which cannot be serialized.
*
* @see import("@wordpress/blocks/src/api/parser").parseRawBlock()
*/
validationIssues,
...rest
} = block;
return {
...rest,
name,
attributes: makeBlockAttributesSerializable( name, attributes ),
innerBlocks: makeBlocksSerializable( innerBlocks ),
};
} );
}
/**
* Recursively walk an attribute value and convert any strings that correspond
* to rich-text schema nodes into RichTextData instances. This is the inverse
* of serializeAttributeValue and handles nested structures like table cells.
*
* @param schema The attribute type definition for this value.
* @param value The attribute value from CRDT (toJSON).
* @return The value with rich-text strings replaced by RichTextData.
*/
function deserializeAttributeValue(
schema: BlockAttributeSchema | undefined,
value: unknown
): unknown {
if ( schema?.type === 'rich-text' && typeof value === 'string' ) {
return getCachedRichTextData( value );
}
// e.g. core/table `body`: [ { cells: [ { content: RichTextData } ] } ]
if ( Array.isArray( value ) ) {
return value.map( ( item ) =>
deserializeAttributeValue( schema, item )
);
}
// e.g. a single row inside core/table `body`: { cells: [ ... ] }
if ( value && typeof value === 'object' ) {
const result: Record< string, unknown > = {};
for ( const [ key, innerValue ] of Object.entries(
value as Record< string, unknown >
) ) {
result[ key ] = deserializeAttributeValue(
schema?.query?.[ key ],
innerValue
);
}
return result;
}
return value;
}
/**
* Convert blocks from their CRDT-serialized form back to the runtime form
* expected by the block editor. Rich-text attributes are stored as Y.Text in
* the CRDT document, which serializes to plain strings via toJSON(). This
* function restores them to RichTextData instances so that block edit
* components that rely on RichTextData methods (e.g. `.text`) work correctly.
*
* @param blocks Blocks as extracted from the CRDT document via toJSON().
* @return Blocks with rich-text attributes restored to RichTextData.
*/
export function deserializeBlockAttributes( blocks: Block[] ): Block[] {
return blocks.map( ( block: Block ) => {
const { name, innerBlocks, attributes, ...rest } = block;
const newAttributes = { ...attributes };
for ( const [ key, value ] of Object.entries( attributes ) ) {
const schema = getBlockAttributeSchema( name, key );
if ( schema ) {
newAttributes[ key ] = deserializeAttributeValue(
schema,
value
);
}
}
return {
...rest,
name,
attributes: newAttributes,
innerBlocks: deserializeBlockAttributes( innerBlocks ?? [] ),
};
} );
}
/**
* @param {any} gblock
* @param {Y.Map} yblock
*/
function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean {
const yblockAsJson = yblock.toJSON();
// we must not sync clientId, as this can't be generated consistently and
// hence will lead to merge conflicts.
const overwrites = {
innerBlocks: null,
clientId: null,
};
const res = fastDeepEqual(
Object.assign( {}, gblock, overwrites ),
Object.assign( {}, yblockAsJson, overwrites )
);
const inners = gblock.innerBlocks || [];
const yinners = yblock.get( 'innerBlocks' );
return (
res &&
inners.length === yinners?.length &&
inners.every( ( block: Block, i: number ) =>
areBlocksEqual( block, yinners.get( i ) )
)
);
}
function createNewYAttributeMap(
blockName: string,
attributes: BlockAttributes
): YBlockAttributes {
return new Y.Map(
Object.entries( attributes ).map(
( [ attributeName, attributeValue ] ) => {
return [
attributeName,
createNewYAttributeValue(
blockName,
attributeName,
attributeValue
),
];
}
)
);
}
function createNewYAttributeValue(
blockName: string,
attributeName: string,
attributeValue: unknown
): Y.Text | Y.Array< unknown > | Y.Map< unknown > | unknown {
const schema = getBlockAttributeSchema( blockName, attributeName );
return createYValueFromSchema( schema, attributeValue );
}
/**
* Recursively create the appropriate Y.js type for a value based on its
* block-attribute schema.
*
* - `rich-text` -> Y.Text
* - `array` with query -> Y.Array of Y.Maps
* - `object` with query -> Y.Map
* - anything else -> plain value (unchanged)
*
* @param schema The attribute type definition.
* @param value The plain JS value to convert.
* @return A Y.js type or the original value.
*/
function createYValueFromSchema(
schema: BlockAttributeSchema | undefined,
value: unknown
): Y.Text | Y.Array< unknown > | Y.Map< unknown > | unknown {
if ( ! schema ) {
return value;
}
if ( schema.type === 'rich-text' ) {
return new Y.Text( value?.toString() ?? '' );
}
if ( schema.type === 'array' && schema.query && Array.isArray( value ) ) {
const query = schema.query;
const yArray = new Y.Array< Y.Map< unknown > >();
yArray.insert(
0,
value.map( ( item ) => createYMapFromQuery( query, item ) )
);
return yArray;
}
if ( schema.type === 'object' && schema.query && isRecord( value ) ) {
return createYMapFromQuery( schema.query, value );
}
return value;
}
/**
* Type guard that narrows `unknown` to `Record< string, unknown >`.
*
* @param value Value to check.
* @return True if `value` is a non-null, non-array object.
*/
function isRecord( value: unknown ): value is Record< string, unknown > {
return !! value && typeof value === 'object' && ! Array.isArray( value );
}
/**
* Create a Y.Map from a plain object, using a query schema to decide which
* properties should become nested Y.js types (Y.Text, Y.Array, Y.Map).
*
* @param query The query schema defining the properties.
* @param obj The plain object to convert.
* @return A Y.Map with typed values.
*/
function createYMapFromQuery(
query: Record< string, BlockAttributeSchema >,
obj: unknown
): Y.Map< unknown > {
if ( ! isRecord( obj ) ) {
return new Y.Map();
}
const entries: [ string, unknown ][] = Object.entries( obj ).map(
( [ key, val ] ): [ string, unknown ] => {
const subSchema = query[ key ];
return [ key, createYValueFromSchema( subSchema, val ) ];
}
);
return new Y.Map( entries );
}
function createNewYBlock( block: Block ): YBlock {
return createYMap< YBlockRecord >(
Object.fromEntries(
Object.entries( block ).map( ( [ key, value ] ) => {
switch ( key ) {
case 'attributes': {
return [
key,
createNewYAttributeMap( block.name, value ),
];
}
case 'innerBlocks': {
const innerBlocks = new Y.Array();
// If not an array, set to empty Y.Array.
if ( ! Array.isArray( value ) ) {
return [ key, innerBlocks ];
}
innerBlocks.insert(
0,
value.map( ( innerBlock: Block ) =>
createNewYBlock( innerBlock )
)
);
return [ key, innerBlocks ];
}
default:
return [ key, value ];
}
} )
)
);
}
/**
* Merge incoming block data into the local Y.Doc.
* This function is called to sync local block changes to a shared Y.Doc.
*
* @param yblocks The blocks in the local Y.Doc.
* @param incomingBlocks Gutenberg blocks being synced.
* @param attributeCursor When provided, describes a selection cursor falling within a
* RichText field associated with a specific block and attribute.
* Derived from the changes that produced the blocks.
* @param options Optional settings for the merge operation.
*/
export function mergeCrdtBlocks(
yblocks: YBlocks,
incomingBlocks: Block[],
attributeCursor: MergeCursorPosition,
options: MergeCrdtBlocksOptions = {}
): void {
// Ensure we are working with serializable block data.
if ( ! serializableBlocksCache.has( incomingBlocks ) ) {
serializableBlocksCache.set(
incomingBlocks,
makeBlocksSerializable( incomingBlocks )
);
}
const incomingBlocksToSync =
serializableBlocksCache.get( incomingBlocks ) ?? [];
// This is a rudimentary diff implementation similar to the y-prosemirror diffing
// approach.
// A better implementation would also diff the textual content and represent it
// using a Y.Text type.
// However, at this time it makes more sense to keep this algorithm generic to
// support all kinds of block types.
// Ideally, we ensure that block data structure have a consistent data format.
// E.g.:
// - textual content (using rich-text formatting?) may always be stored under `block.text`
// - local information that shouldn't be shared (e.g. clientId or isDragging) is stored under `block.private`
//
// @credit Kevin Jahns (dmonad)
// @link https://github.com/WordPress/gutenberg/pull/68483
const numOfCommonEntries = Math.min(
incomingBlocksToSync.length ?? 0,
yblocks.length
);
let left = 0;
let right = 0;
// skip equal blocks from left
for (
;
left < numOfCommonEntries &&
areBlocksEqual( incomingBlocksToSync[ left ], yblocks.get( left ) );
left++
) {
/* nop */
}
// skip equal blocks from right
for (
;
right < numOfCommonEntries - left &&
areBlocksEqual(
incomingBlocksToSync[ incomingBlocksToSync.length - right - 1 ],
yblocks.get( yblocks.length - right - 1 )
);
right++
) {
/* nop */
}
const numOfUpdatesNeeded = numOfCommonEntries - left - right;
const numOfInsertionsNeeded = Math.max(
0,
incomingBlocksToSync.length - yblocks.length
);
const numOfDeletionsNeeded = Math.max(
0,
yblocks.length - incomingBlocksToSync.length
);
// updates
for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) {
const incomingYBlock = incomingBlocksToSync[ left ];
const localYBlock = yblocks.get( left );
Object.entries( incomingYBlock ).forEach(
( [ incomingBlockProperty, incomingBlockPropertyValue ] ) => {
switch ( incomingBlockProperty ) {
case 'attributes': {
const localAttributes = localYBlock.get(
incomingBlockProperty
);
const incomingAttributes = incomingBlockPropertyValue;
// When the local block has no attributes, adopt the incoming set.
if ( ! localAttributes ) {
localYBlock.set(
incomingBlockProperty,
createNewYAttributeMap(
incomingYBlock.name,
incomingAttributes
)
);
break;
}
// Otherwise the attributes need to be merged.
Object.entries( incomingAttributes ).forEach(
( [
incomingAttributeName,
incomingAttributeValue,
] ) => {
const currentAttribute = localAttributes?.get(
incomingAttributeName
);
const isExpectedType = isExpectedAttributeType(
incomingYBlock.name,
incomingAttributeName,
currentAttribute
);
// Y types (Y.Text, Y.Array, Y.Map) cannot be
// compared with fastDeepEqual against plain values.
// Delegate to mergeYValue which handles no-op
// detection at the edges.
const isYType =
currentAttribute instanceof Y.AbstractType;
const isAttributeChanged =
! isExpectedType ||
isYType ||
! fastDeepEqual(
currentAttribute,
incomingAttributeValue
);
if ( isAttributeChanged ) {
updateYBlockAttribute(
incomingYBlock.name,
incomingYBlock.clientId,
incomingAttributeName,
incomingAttributeValue,
localAttributes,
attributeCursor
);
}
}
);
// Delete any attributes that are no longer present.
localAttributes.forEach(
( _attrValue: unknown, attrName: string ) => {
if (
! incomingBlockPropertyValue.hasOwnProperty(
attrName
)
) {
localAttributes.delete( attrName );
}
}
);
break;
}
case 'innerBlocks': {
// Recursively merge innerBlocks
let yInnerBlocks = localYBlock.get(
incomingBlockProperty
);
if ( ! ( yInnerBlocks instanceof Y.Array ) ) {
yInnerBlocks = new Y.Array< YBlock >();
localYBlock.set(
incomingBlockProperty,
yInnerBlocks
);
}
mergeCrdtBlocks(
yInnerBlocks,
incomingBlockPropertyValue ?? [],
attributeCursor,
options
);
break;
}
case 'clientId': {
// Code Editor changes reparse raw HTML on every
// keystroke and regenerate fresh clientIds. Keep Y.Doc
// clientIds stable for the code editor so peers do not
// remount unchanged blocks on every edit.
if ( options.preserveClientIds ) {
break;
}
// Otherwise, accept new clientIds from updates
if (
incomingBlockPropertyValue !==
localYBlock.get( incomingBlockProperty )
) {
localYBlock.set(
incomingBlockProperty,
incomingBlockPropertyValue
);
}
break;
}
default:
if (
! fastDeepEqual(
incomingYBlock[ incomingBlockProperty ],
localYBlock.get( incomingBlockProperty )
)
) {
localYBlock.set(
incomingBlockProperty,
incomingBlockPropertyValue
);
}
}
}
);
localYBlock.forEach( ( _v, k ) => {
if ( ! incomingYBlock.hasOwnProperty( k ) ) {
localYBlock.delete( k );
}
} );
}
// deletes
yblocks.delete( left, numOfDeletionsNeeded );
// inserts
for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) {
const newBlock = [ createNewYBlock( incomingBlocksToSync[ left ] ) ];
yblocks.insert( left, newBlock );
}
// remove duplicate clientids
const knownClientIds = new Set< string >();
for ( let j = 0; j < yblocks.length; j++ ) {
const yblock: YBlock = yblocks.get( j );
let clientId = yblock.get( 'clientId' );
if ( ! clientId ) {
continue;
}
if ( knownClientIds.has( clientId ) ) {
clientId = uuidv4();
yblock.set( 'clientId', clientId );
}
knownClientIds.add( clientId );
}
}
/**
* Compare a plain array element against a Y.Map element for equality.
* Used by the left-right sweep diff in mergeYArray.
*
* @param newElement The plain object from the incoming array.
* @param yElement The Y.Map element from the existing Y.Array.
* @return True if the elements are deeply equal.
*/
function areArrayElementsEqual(
newElement: unknown,
yElement: unknown
): boolean {
if ( yElement instanceof Y.Map && isRecord( newElement ) ) {
return fastDeepEqual( newElement, yElement.toJSON() );
}
return fastDeepEqual( newElement, yElement );
}
/**
* Merge an incoming plain array into an existing Y.Array in-place.
*
* Uses the same left-right sweep diff approach as mergeCrdtBlocks:
* equal elements are skipped from both ends, then the middle section
* is updated, deleted, or inserted as needed. This preserves existing
* Y.Map/Y.Text objects for unchanged elements, so concurrent edits
* to those elements are not lost.
*
* @param yArray The existing Y.Array to update.
* @param newValue The new plain array to merge into the Y.Array.
* @param schema The attribute schema (must have `query`).
* @param cursorPosition The local cursor position for rich-text delta merges.
* @param cursorScope The selected block attribute scope for rich-text cursor hints.
*/
function mergeYArray(
yArray: Y.Array< unknown >,
newValue: unknown[],
schema: BlockAttributeSchema,
cursorPosition: MergeCursorPosition,
cursorScope: RichTextCursorScope
): void {
if ( ! schema.query ) {
return;
}
const query = schema.query;
const numOfCommonEntries = Math.min( newValue.length, yArray.length );
let left = 0;
let right = 0;
// Skip equal elements from left.
for (
;
left < numOfCommonEntries &&
areArrayElementsEqual( newValue[ left ], yArray.get( left ) );
left++
) {
/* nop */
}
// Skip equal elements from right.
for (
;
right < numOfCommonEntries - left &&
areArrayElementsEqual(
newValue[ newValue.length - right - 1 ],
yArray.get( yArray.length - right - 1 )
);
right++
) {
/* nop */
}
// Updates: merge changed elements in-place.
const numOfUpdatesNeeded = numOfCommonEntries - left - right;
for ( let i = 0; i < numOfUpdatesNeeded; i++ ) {
const currentElement = yArray.get( left + i );
const newElement = newValue[ left + i ];
if ( currentElement instanceof Y.Map && isRecord( newElement ) ) {
mergeYMapValues(
currentElement,
newElement,
query,
cursorPosition,
cursorScope
);
} else {
// Element is the wrong type (e.g. partial migration) or the
// incoming value is not an object. Rebuild the entire array.
yArray.delete( 0, yArray.length );
yArray.insert(
0,
newValue.map( ( item ) => createYMapFromQuery( query, item ) )
);
return;
}
}
// Deletes.
const numOfDeletionsNeeded = Math.max( 0, yArray.length - newValue.length );
if ( numOfDeletionsNeeded > 0 ) {
yArray.delete( left + numOfUpdatesNeeded, numOfDeletionsNeeded );
}
// Inserts.
const numOfInsertionsNeeded = Math.max(
0,
newValue.length - yArray.length
);
if ( numOfInsertionsNeeded > 0 ) {
const insertAt = left + numOfUpdatesNeeded;
const itemsToInsert: Y.Map< unknown >[] = new Array(
numOfInsertionsNeeded
);
for ( let i = 0; i < numOfInsertionsNeeded; i++ ) {
itemsToInsert[ i ] = createYMapFromQuery(
query,
newValue[ insertAt + i ]
);
}
yArray.insert( insertAt, itemsToInsert );
}
}
/**
* Merge a single value into a Y.Map entry, using the attribute schema to
* decide how to merge.
*
* If the current value is already a matching Y.js type (Y.Text, Y.Array,
* Y.Map), the update is merged in-place so concurrent edits are preserved.
* Otherwise the value is replaced wholesale.
*
* @param schema The attribute type definition for this value.
* @param newVal The new value to merge into the Y.Map entry.
* @param yMap The Y.Map that owns this entry.
* @param key The key of this entry in the Y.Map.
* @param cursorPosition The cursor position for rich-text delta merges from the updated value.
* @param cursorScope Indicates a specific block and attribute associated with the editor;
* determines whether the cursor should be updated based on the change.
*/
function mergeYValue(
schema: BlockAttributeSchema | undefined,
newVal: unknown,
yMap: Y.Map< unknown >,
key: string,
cursorPosition: MergeCursorPosition,
cursorScope: RichTextCursorScope
): void {
const currentVal = yMap.get( key );
if (
schema?.type === 'rich-text' &&
typeof newVal === 'string' &&
currentVal instanceof Y.Text
) {
mergeRichTextUpdate(
currentVal,
newVal,
resolveRichTextCursorPosition( cursorPosition, cursorScope, newVal )
);
} else if (
schema?.type === 'array' &&
schema.query &&
Array.isArray( newVal ) &&
currentVal instanceof Y.Array
) {
mergeYArray( currentVal, newVal, schema, cursorPosition, cursorScope );
} else if (
schema?.type === 'object' &&
schema.query &&
isRecord( newVal ) &&
currentVal instanceof Y.Map
) {
mergeYMapValues(
currentVal,
newVal,
schema.query,
cursorPosition,
cursorScope
);
} else {
const newYValue = createYValueFromSchema( schema, newVal );
// If createYValueFromSchema wrapped the value into a Y type, the
// current value is the wrong type and needs upgrading. Otherwise,
// only replace if the raw value actually changed.
if ( newYValue !== newVal || ! fastDeepEqual( currentVal, newVal ) ) {
yMap.set( key, newYValue );
}
}
}
/**
* Merge an incoming plain object into an existing Y.Map in-place, using
* the query schema to decide how each property should be merged.
*
* Properties present in the Y.Map but absent from `newObj` are deleted.
*
* @param yMap The existing Y.Map to update.
* @param newObj The new plain object to merge into the Y.Map.
* @param query The query schema defining property types.
* @param cursorPosition The local cursor position for rich-text delta merges.
* @param cursorScope The selected block attribute scope for rich-text cursor hints.
*/
function mergeYMapValues(
yMap: Y.Map< unknown >,
newObj: Record< string, unknown >,
query: Record< string, BlockAttributeSchema >,
cursorPosition: MergeCursorPosition,
cursorScope: RichTextCursorScope
): void {
for ( const [ key, newVal ] of Object.entries( newObj ) ) {
mergeYValue(
query[ key ],
newVal,
yMap,
key,
cursorPosition,
cursorScope
);
}
// Delete properties absent from the incoming object.
for ( const key of yMap.keys() ) {
if ( ! Object.hasOwn( newObj, key ) ) {
yMap.delete( key );
}
}
}
/**
* Update a single attribute on a Yjs block attributes map (currentAttributes).
*
* @param blockName The block type name, e.g. 'core/paragraph'.
* @param clientId The local clientId for the block being merged.
* @param attributeName The name of the attribute to update, e.g. 'content'.
* @param attributeValue The new value for the attribute.
* @param currentAttributes The Y.Map holding the block's current attributes.
* @param newCursorPosition The cursor position for rich-text delta merges from the updated value.
* Notably, this may not correspond to the attribute being edited and is
* used to determine if any cursors need shifting in response to the change.
*/
function updateYBlockAttribute(
blockName: string,
clientId: string | undefined,
attributeName: string,
attributeValue: unknown,
currentAttributes: YBlockAttributes,
newCursorPosition: MergeCursorPosition
): void {
const schema = getBlockAttributeSchema( blockName, attributeName );
/*
* @todo There is a slight discrepancy between the attribute name and key, which might
* show up when working with multiline RichText instances (of which there are no
* more within Core). For those instances, a cursor might never be updated in
* response to changes because its `attributeKey` won’t match any of the block’s
* attribute names, and since updating this attribute is based on the block names,
* no suitable key for the cursor scope will be created. To fix, the updating code
* would need to parse multiline attributes and infer the `attributeKey` being updated.
*/
mergeYValue(
schema,
attributeValue,
currentAttributes,
attributeName,
newCursorPosition,
{ attributeKey: attributeName, clientId }
);
}
/**
* References the specific block and attribute associated with a RichText component.
*
* This is used to associate a cursor with the attribute it’s editing.
*
* @see WPBlockSelection
*/
interface RichTextCursorScope {
attributeKey: string;
clientId: string | undefined;
}
interface DeltaWithOps {
ops: Parameters< Y.Text[ 'applyDelta' ] >[ 0 ];
}
/**
* When the provided cursor falls within the given block and attribute’s scope,
* returns an index into the RichText’s serialized HTML where the cursor falls.
*
* The cursor scope constrains resolution to ensure that indices are only reported
* when a cursor falls within the block and attribute being updated, since the
* attributes being updated may not always be the ones where a cursor presently falls.
*
* Returned index measures JS string lengths, thus is counted in UTF-16 code units
* and contains the syntax characters making up HTML tags, comments, character
* references, and other non-plaintext content.
*
* @param cursorPosition Description of the cursor in the new value.
* @param cursorScope Cursors should only be updated if they fall within this
* specific block and attribute.
* @param updatedValue New RichText value potentially containing cursor.
* @return String length into serialized HTML for RichText instance where cursor falls.
*/
function resolveRichTextCursorPosition(
cursorPosition: MergeCursorPosition,
cursorScope: RichTextCursorScope,
updatedValue: string
): HtmlStringIndex | null {
return cursorPosition &&
cursorPosition.clientId === cursorScope.clientId &&
cursorPosition.attributeKey === cursorScope.attributeKey &&
'number' === typeof cursorPosition.offset &&
Number.isInteger( cursorPosition.offset )
? richTextOffsetToHtmlIndex(
updatedValue,
asRichTextOffset( cursorPosition.offset )
)
: null;
}
// Cached block attribute types, populated once from getBlockTypes().
let cachedBlockAttributeSchemas: Map<
string,
Map< string, BlockAttributeSchema >
>;
/**
* Get the attribute type definition for a block attribute.
*
* @param blockName The name of the block, e.g. 'core/paragraph'.
* @param attributeName The name of the attribute, e.g. 'content'.
* @return The type definition of the attribute.
*/
function getBlockAttributeSchema(
blockName: string,
attributeName: string
): BlockAttributeSchema | undefined {
if ( ! cachedBlockAttributeSchemas ) {
// Parse the attributes for all blocks once.
cachedBlockAttributeSchemas = new Map();
for ( const blockType of getBlockTypes() as BlockType[] ) {
cachedBlockAttributeSchemas.set(
blockType.name,
new Map< string, BlockAttributeSchema >(
Object.entries( blockType.attributes ?? {} ).map(
( [ name, definition ] ) => {
const { role, type, query } = definition;
return [ name, { role, type, query } ];
}
)
)
);
}
}
return cachedBlockAttributeSchemas.get( blockName )?.get( attributeName );
}
/**
* Check if an attribute value is the expected type.
*
* @param blockName The name of the block, e.g. 'core/paragraph'.
* @param attributeName The name of the attribute, e.g. 'content'.
* @param attributeValue The current attribute value.
* @return True if the attribute type is expected, false otherwise.
*/
function isExpectedAttributeType(
blockName: string,
attributeName: string,
attributeValue: unknown
): boolean {
const schema = getBlockAttributeSchema( blockName, attributeName );
if ( schema?.type === 'rich-text' ) {
return attributeValue instanceof Y.Text;
}
if ( schema?.type === 'string' ) {
return typeof attributeValue === 'string';
}
if ( schema?.type === 'array' && schema.query ) {
return attributeValue instanceof Y.Array;
}
if ( schema?.type === 'object' && schema.query ) {
return attributeValue instanceof Y.Map;
}
return true;
}
/**
* Given a block name and attribute key, return true if the attribute is local
* and should not be synced.
*
* @param blockName The name of the block, e.g. 'core/image'.
* @param attributeName The name of the attribute to check, e.g. 'blob'.
* @return True if the attribute is local, false otherwise.
*/
function isLocalAttribute( blockName: string, attributeName: string ): boolean {
return (
'local' === getBlockAttributeSchema( blockName, attributeName )?.role
);
}
let localDoc: Y.Doc;
/**
* Given a Y.Text object and an updated string value, diff the new value and
* apply the delta to the Y.Text.
*
* @param blockYText The Y.Text to update.
* @param updatedValue The updated value.
* @param htmlCursorIndex The cursor index in the updated HTML string.
*/
export function mergeRichTextUpdate(
blockYText: Y.Text,
updatedValue: string,
htmlCursorIndex: HtmlStringIndex | null = null
): void {
// Gutenberg does not use Yjs shared types natively, so we can only subscribe
// to changes from store and apply them to Yjs types that we create and
// manage. Crucially, for rich-text attributes, we do not receive granular
// string updates; we get the new full string value on each change, even when
// only a single character changed.
//
// The code below allows us to compute a delta between the current and new
// value, then apply it to the Y.Text.
const currentValueAsDelta = new Delta( blockYText.toDelta() );
const updatedValueAsDelta = new Delta( [ { insert: updatedValue } ] );
const deltaDiff = currentValueAsDelta.diffWithCursor(
updatedValueAsDelta,
htmlCursorIndex
);
/**
* When there is no cursor involved, or when the diff is able to shuffle properly
* around the cursor then apply that already-computed diff.
*
* However, `diffWithCursor()` currently fails in certain cases, producing corrupted
* output. In these cases, fall back to the raw diff as that will apply cleanly,
* even if it provides a less meaningful diff.
*
* @see Delta.diffWithCursor()
*/
const safeDiff =
htmlCursorIndex === null ||
isDeltaVerificationMatch( blockYText, deltaDiff, updatedValue )
? deltaDiff
: currentValueAsDelta.diff( updatedValueAsDelta );
blockYText.applyDelta( safeDiff.ops );
}
/**
* Verify that applying a delta to an existing Y.Text object produces the expected
* output string.
*
* A stale, mis-scoped, or corrupted Delta will mutate a text value to the wrong
* output string. This function applies the given Delta and indicates whether it
* produces the given expected output string value.
*
* @param blockYText The current Y.Text before applying the candidate delta.
* @param delta The candidate delta.
* @param expectedValue The exact string expected after applying the delta.
* @return Whether the candidate delta produces the expected value.
*/
function isDeltaVerificationMatch(
blockYText: Y.Text,
delta: DeltaWithOps,
expectedValue: string
): boolean {
if ( ! localDoc ) {
// Y.Text must be attached to a Y.Doc to be able to do operations on it.
// Create a temporary Y.Text attached to a local Y.Doc for delta computation.
// This is an optimization to avoid creating a new Y.Doc on every update.
localDoc = new Y.Doc();
}
const verificationYText = localDoc.getText( 'verification-text' );
// Because this is global, it must be cleared before using.
verificationYText.delete( 0, verificationYText.length );
verificationYText.insert( 0, blockYText.toString() );
verificationYText.applyDelta( delta.ops );
return verificationYText.toString() === expectedValue;
}