/** * External dependencies */ import { v4 as uuidv4 } from 'uuid'; import fastDeepEqual from 'fast-deep-equal/es6/index.js'; /** * WordPress dependencies */ import { getBlockTypes } from '@wordpress/blocks'; import { RichTextData } from '@wordpress/rich-text'; import { Y } from '@wordpress/sync'; /** * Internal dependencies */ import { createYMap, type YMapRecord, type YMapWrap } from './crdt-utils'; import { getCachedRichTextData } from './crdt-text'; import { Delta } from '../sync'; 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[]; 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 >; 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; } function makeBlocksSerializable( blocks: Block[] ): Block[] { return blocks.map( ( block: Block ) => { const { name, innerBlocks, attributes, ...rest } = block; delete rest.validationIssues; 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 cursorPosition The position of the cursor after the change occurs. */ export function mergeCrdtBlocks( yblocks: YBlocks, incomingBlocks: Block[], cursorPosition: number | null ): void { // Ensure we are working with serializable block data. if ( ! serializableBlocksCache.has( incomingBlocks ) ) { serializableBlocksCache.set( incomingBlocks, makeBlocksSerializable( incomingBlocks ) ); } const blocksToSync = 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( blocksToSync.length ?? 0, yblocks.length ); let left = 0; let right = 0; // skip equal blocks from left for ( ; left < numOfCommonEntries && areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) ); left++ ) { /* nop */ } // skip equal blocks from right for ( ; right < numOfCommonEntries - left && areBlocksEqual( blocksToSync[ blocksToSync.length - right - 1 ], yblocks.get( yblocks.length - right - 1 ) ); right++ ) { /* nop */ } const numOfUpdatesNeeded = numOfCommonEntries - left - right; const numOfInsertionsNeeded = Math.max( 0, blocksToSync.length - yblocks.length ); const numOfDeletionsNeeded = Math.max( 0, yblocks.length - blocksToSync.length ); // updates for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { const block = blocksToSync[ left ]; const yblock = yblocks.get( left ); Object.entries( block ).forEach( ( [ key, value ] ) => { switch ( key ) { case 'attributes': { const currentAttributes = yblock.get( key ); // If attributes are not set on the yblock, use the new values. if ( ! currentAttributes ) { yblock.set( key, createNewYAttributeMap( block.name, value ) ); break; } Object.entries( value ).forEach( ( [ attributeName, attributeValue ] ) => { const currentAttribute = currentAttributes?.get( attributeName ); const isExpectedType = isExpectedAttributeType( block.name, attributeName, 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, attributeValue ); if ( isAttributeChanged ) { updateYBlockAttribute( block.name, attributeName, attributeValue, currentAttributes, cursorPosition ); } } ); // Delete any attributes that are no longer present. currentAttributes.forEach( ( _attrValue: unknown, attrName: string ) => { if ( ! value.hasOwnProperty( attrName ) ) { currentAttributes.delete( attrName ); } } ); break; } case 'innerBlocks': { // Recursively merge innerBlocks let yInnerBlocks = yblock.get( key ); if ( ! ( yInnerBlocks instanceof Y.Array ) ) { yInnerBlocks = new Y.Array< YBlock >(); yblock.set( key, yInnerBlocks ); } mergeCrdtBlocks( yInnerBlocks, value ?? [], cursorPosition ); break; } default: if ( ! fastDeepEqual( block[ key ], yblock.get( key ) ) ) { yblock.set( key, value ); } } } ); yblock.forEach( ( _v, k ) => { if ( ! block.hasOwnProperty( k ) ) { yblock.delete( k ); } } ); } // deletes yblocks.delete( left, numOfDeletionsNeeded ); // inserts for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { const newBlock = [ createNewYBlock( blocksToSync[ 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. */ function mergeYArray( yArray: Y.Array< unknown >, newValue: unknown[], schema: BlockAttributeSchema, cursorPosition: number | null ): 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 ); } 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 local cursor position for rich-text delta merges. */ function mergeYValue( schema: BlockAttributeSchema | undefined, newVal: unknown, yMap: Y.Map< unknown >, key: string, cursorPosition: number | null ): void { const currentVal = yMap.get( key ); if ( schema?.type === 'rich-text' && typeof newVal === 'string' && currentVal instanceof Y.Text ) { mergeRichTextUpdate( currentVal, newVal, cursorPosition ); } else if ( schema?.type === 'array' && schema.query && Array.isArray( newVal ) && currentVal instanceof Y.Array ) { mergeYArray( currentVal, newVal, schema, cursorPosition ); } else if ( schema?.type === 'object' && schema.query && isRecord( newVal ) && currentVal instanceof Y.Map ) { mergeYMapValues( currentVal, newVal, schema.query, cursorPosition ); } 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. */ function mergeYMapValues( yMap: Y.Map< unknown >, newObj: Record< string, unknown >, query: Record< string, BlockAttributeSchema >, cursorPosition: number | null ): void { for ( const [ key, newVal ] of Object.entries( newObj ) ) { mergeYValue( query[ key ], newVal, yMap, key, cursorPosition ); } // 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 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 cursorPosition The local cursor position, used when merging rich-text deltas. */ function updateYBlockAttribute( blockName: string, attributeName: string, attributeValue: unknown, currentAttributes: YBlockAttributes, cursorPosition: number | null ): void { const schema = getBlockAttributeSchema( blockName, attributeName ); mergeYValue( schema, attributeValue, currentAttributes, attributeName, cursorPosition ); } // 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 cursorPosition The position of the cursor after the change occurs. */ export function mergeRichTextUpdate( blockYText: Y.Text, updatedValue: string, cursorPosition: number | 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. 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. localDoc = new Y.Doc(); } const localYText = localDoc.getText( 'temporary-text' ); localYText.delete( 0, localYText.length ); localYText.insert( 0, updatedValue ); const currentValueAsDelta = new Delta( blockYText.toDelta() ); const updatedValueAsDelta = new Delta( localYText.toDelta() ); const deltaDiff = currentValueAsDelta.diffWithCursor( updatedValueAsDelta, cursorPosition ); blockYText.applyDelta( deltaDiff.ops ); }