/
githubmirror
/
gutenberg
Обзор
Документация
Войти
/
githubmirror
/
gutenberg
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
trunk
packages/core-data/src/utils/crdt.ts
608 строк
18 KB
Marco Ciampini
ESLint: Replace strict config with bulk suppressions (#81248)
07 авг 2026, 14:34
Не верифицирован
07 авг 2026, 14:34
7f43eaf
Код
Авторство
О чём код?
import fastDeepEqual from 'fast-deep-equal/es6/index.js'; import { __unstableSerializeAndClean, parse, type Block as WPBlock, } from '@wordpress/blocks'; import { type CRDTDoc, type ObjectData, type ObjectID, type ObjectType, type SyncConfig, Y, } from '@wordpress/sync'; import { BaseAwareness } from '../awareness/base-awareness'; import { type Block, deserializeBlockAttributes, mergeCrdtBlocks, type MergeCursorPosition, mergeRichTextUpdate, type YBlock, type YBlocks, } from './crdt-blocks'; import { type Post } from '../entity-types/post'; import { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY } from '../sync'; import type { WPSelection } from '../types'; import { getSelectionHistory, getShiftedSelection, updateSelectionHistory, } from './crdt-selection'; import { asRichTextOffset, createYMap, getRootMap, isYMap, type YMapRecord, type YMapWrap, } from './crdt-utils'; // A function that derives content from blocks. Two callers produce this: // `useEntityBlockEditor` reads blocks from its argument (so the optional arg // lets it accept whatever caller is invoked with), and the receiver-side // injection in this file captures blocks in a closure and ignores the arg. type ContentFromBlocksFn = ( args?: { blocks: Block[] } ) => string; // Changes that can be applied to a post entity record. export type PostChanges = Partial< Post > & { blocks?: Block[]; content?: Post[ 'content' ] | string | ContentFromBlocksFn; excerpt?: Post[ 'excerpt' ] | string; selection?: WPSelection; title?: Post[ 'title' ] | string; }; // A post record as represented in the CRDT document (Y.Map). export interface YPostRecord extends YMapRecord { author: number; // Blocks are undefined when they need to be re-parsed from content. blocks: YBlocks | undefined; content: Y.Text; categories: number[]; comment_status: string; date: string | null; excerpt: Y.Text; featured_media: number; format: string; meta: YMapWrap< YMapRecord >; ping_status: string; slug: string; status: string; sticky: boolean; tags: number[]; template: string; title: Y.Text; } export const POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE = '_crdt_document'; // Post meta keys that should *not* be synced. const disallowedPostMetaKeys = new Set< string >( [ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE, ] ); /** * Given a set of local changes to a generic entity record, apply those changes * to the local Y.Doc. * * @param {CRDTDoc} ydoc * @param {Partial< ObjectData >} changes * @return {void} */ function defaultApplyChangesToCRDTDoc( ydoc: CRDTDoc, changes: ObjectData ): void { const ymap = getRootMap( ydoc, CRDT_RECORD_MAP_KEY ); Object.entries( changes ).forEach( ( [ key, newValue ] ) => { // Cannot serialize function values, so cannot sync them. if ( 'function' === typeof newValue ) { return; } switch ( key ) { // Add support for additional data types here. default: { const currentValue = ymap.get( key ); updateMapValue( ymap, key, currentValue, newValue ); } } } ); } /** * Given a set of local changes to a post record, apply those changes to the * local Y.Doc. * * @param {CRDTDoc} ydoc * @param {PostChanges} changes * @param {Set<string>} syncedProperties * @return {void} */ export function applyPostChangesToCRDTDoc( ydoc: CRDTDoc, changes: PostChanges, syncedProperties: Set< string > ): void { const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY ); Object.keys( changes ).forEach( ( key ) => { if ( ! syncedProperties.has( key ) ) { return; } const newValue = changes[ key ]; // Cannot serialize function values, so cannot sync them. `content` is // often passed as a lazy serializer by `useEntityBlockEditor`; the // receiver re-derives it from the synced blocks (see // getPostChangesFromCRDTDoc), so dropping it here is intentional. if ( 'function' === typeof newValue ) { return; } switch ( key ) { case 'blocks': { // Block changes from typing are bundled with a 'selection' update. // Use the resulting cursor position for block merging. const newCursorPosition = parseCursorSelection( changes.selection ); // Blocks are undefined when they need to be re-parsed from content. // When new content is also part of this change (e.g. the Code // Editor dispatching `{ content, blocks: undefined }` on every // keystroke), derive blocks from content so the merge keeps // stable YBlock identities for unchanged blocks. const rawContent = getRawValue( changes.content ); if ( ! newValue && typeof rawContent === 'string' ) { // We have no blocks but an updated content string. mergeContentWithoutBlocks( ymap, rawContent, newCursorPosition ); break; } else if ( ! newValue ) { // We have an update containing empty blocks and content. // Set to undefined instead of deleting the key. This is important // since we iterate over the Y.Map keys in getPostChangesFromCRDTDoc. ymap.set( key, undefined ); break; } let currentBlocks = ymap.get( key ); // Initialize. if ( ! ( currentBlocks instanceof Y.Array ) ) { currentBlocks = new Y.Array< YBlock >(); ymap.set( key, currentBlocks ); } // Merge blocks does not need `setValue` because it is operating on a // Yjs type that is already in the Y.Doc. mergeCrdtBlocks( currentBlocks, newValue, newCursorPosition ); break; } case 'content': case 'excerpt': case 'title': { const currentValue = ymap.get( key ); let rawValue = getRawValue( newValue ); // Copy logic from prePersistPostType to ensure that the "Auto // Draft" template title is not synced. if ( key === 'title' && ! currentValue?.toString() && 'Auto Draft' === rawValue ) { rawValue = ''; } if ( currentValue instanceof Y.Text ) { mergeRichTextUpdate( currentValue, rawValue ?? '' ); } else { const newYText = new Y.Text( rawValue ?? '' ); ymap.set( key, newYText ); } break; } // "Meta" is overloaded term; here, it refers to post meta. case 'meta': { let metaMap = ymap.get( 'meta' ); // Initialize. if ( ! isYMap( metaMap ) ) { metaMap = createYMap< YMapRecord >(); ymap.set( 'meta', metaMap ); } // Iterate over each meta property in the new value and merge it if it // should be synced. Object.entries( newValue ?? {} ).forEach( ( [ metaKey, metaValue ] ) => { if ( disallowedPostMetaKeys.has( metaKey ) ) { return; } updateMapValue( metaMap, metaKey, metaMap.get( metaKey ), // current value in CRDT metaValue // new value from changes ); } ); break; } case 'slug': { // Do not sync an empty slug. This indicates that the post is using // the default auto-generated slug. if ( ! newValue ) { break; } const currentValue = ymap.get( key ); updateMapValue( ymap, key, currentValue, newValue ); break; } // Add support for additional properties here. default: { const currentValue = ymap.get( key ); updateMapValue( ymap, key, currentValue, newValue ); } } } ); // Process changes that we don't want to persist to the CRDT document. if ( changes.selection ) { const selection = changes.selection; // Persist selection changes at the end of the current event loop. // This allows undo meta to be saved with the current selection before // it is overwritten by the new selection from Gutenberg. // Without this, selection history will already contain the latest // selection (after this change) when the undo stack is saved. setTimeout( () => { updateSelectionHistory( ydoc, selection ); }, 0 ); } } /** * Derive blocks from a raw content string and merge them into the post's * blocks Y.Array. Used when a caller dispatches a change with `blocks: * undefined` alongside new content, most notably the Code Editor's * per-keystroke dispatch. * * @param ymap The post's root Y.Map. * @param rawContent The raw HTML content to parse. * @param cursorPosition Cursor position derived from the change's selection, * used by mergeCrdtBlocks for rich-text cursor hints. */ function mergeContentWithoutBlocks( ymap: YMapWrap< YPostRecord >, rawContent: string, cursorPosition: MergeCursorPosition ): void { let currentBlocks = ymap.get( 'blocks' ); if ( ! ( currentBlocks instanceof Y.Array ) ) { currentBlocks = new Y.Array< YBlock >(); ymap.set( 'blocks', currentBlocks ); } mergeCrdtBlocks( currentBlocks, parse( rawContent ) as Block[], cursorPosition, { preserveClientIds: true } ); } /** * Only returns a selection object if it describes a selection within a block, with * a cursor inside a RichText field associated with one of that block’s attributes. * * @param selection Selection object which might represent a selection within a block, * within a RichText field associated with a particular attribute of * that block, or none at all. */ function parseCursorSelection( selection?: WPSelection ): MergeCursorPosition { const selectionStart = selection?.selectionStart; return selectionStart?.clientId && selectionStart.attributeKey && 'number' === typeof selectionStart.offset && Number.isInteger( selectionStart.offset ) ? { attributeKey: selectionStart.attributeKey, clientId: selectionStart.clientId, offset: asRichTextOffset( selectionStart.offset ), } : null; } function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc, editedRecord: ObjectData ): ObjectData { const docRecord = getRootMap( crdtDoc, CRDT_RECORD_MAP_KEY ).toJSON(); /* * Only report properties that differ from the edited record. Reporting * unchanged properties as edits marks the record dirty: `Y.Map.toJSON()` * returns fresh object instances, so without this comparison every synced * update (e.g. from another tab) re-dispatches the entire record as edits. * See https://github.com/WordPress/gutenberg/issues/79907. */ return Object.fromEntries( Object.entries( docRecord ).filter( ( [ key, newValue ] ) => haveValuesChanged( editedRecord?.[ key ], newValue ) ) ); } /** * Given a local Y.Doc that *may* contain changes from remote peers, compare * against the local record and determine if there are changes (edits) we want * to dispatch. * * @param {CRDTDoc} ydoc * @param {Post} editedRecord * @param {Set<string>} syncedProperties * @return {Partial<PostChanges>} The changes that should be applied to the local record. */ export function getPostChangesFromCRDTDoc( ydoc: CRDTDoc, editedRecord: Post, syncedProperties: Set< string > ): PostChanges { const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY ); let allowedMetaChanges: Post[ 'meta' ] = {}; const changes = Object.fromEntries( Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => { if ( ! syncedProperties.has( key ) ) { return false; } const currentValue = editedRecord[ key ]; switch ( key ) { case 'blocks': { // When we are passed a persisted CRDT document, make a special // comparison of the content and blocks. // // When other fields (besides `blocks`) are mutated outside the block // editor, the change is caught by an equality check (see other cases // in this `switch` statement). As a transient property, `blocks` // cannot be directly mutated outside the block editor -- only // `content` can. // // Therefore, for this special comparison, we serialize the `blocks` // from the persisted CRDT document and compare that to the content // from the persisted record. If they differ, we know that the content // in the database has changed, and therefore the blocks have changed. // // We cannot directly compare the `blocks` from the CRDT document to // the `blocks` derived from the `content` in the persisted record, // because the latter will have different client IDs. if ( ydoc.meta?.get( CRDT_DOC_META_PERSISTENCE_KEY ) && editedRecord.content ) { const blocksJson = ymap.get( 'blocks' )?.toJSON() ?? []; return ( __unstableSerializeAndClean( blocksJson ).trim() !== getRawValue( editedRecord.content ) ); } return true; } case 'date': { // Do not overwrite a "floating" date. Borrowing logic from the // isEditedPostDateFloating selector. const currentDateIsFloating = null === currentValue || editedRecord.modified === currentValue; if ( currentDateIsFloating ) { return false; } return haveValuesChanged( currentValue, newValue ); } case 'meta': { const currentMeta = ( currentValue as PostChanges[ 'meta' ] ) ?? {}; allowedMetaChanges = Object.fromEntries( Object.entries( newValue ?? {} ).filter( ( [ metaKey ] ) => { if ( disallowedPostMetaKeys.has( metaKey ) ) { return false; } // Ignore meta keys that are no longer registered // for this post (absent from the REST response). // Without this, orphaned CRDT meta would mark // the post permanently dirty. return metaKey in currentMeta; } ) ); // Merge the allowed meta changes with the current meta values since // not all meta properties are synced. const mergedValue = { ...currentMeta, ...allowedMetaChanges, }; return haveValuesChanged( currentValue, mergedValue ); } case 'status': { // Do not sync an invalid status. if ( 'auto-draft' === newValue ) { return false; } return haveValuesChanged( currentValue, newValue ); } case 'content': case 'excerpt': case 'title': { return haveValuesChanged( getRawValue( currentValue ), newValue ); } // Add support for additional data types here. default: { return haveValuesChanged( currentValue, newValue ); } } } ) ); // Blocks extracted from the CRDT document have rich-text attributes as // plain strings (from Y.Text.toJSON()). Convert them back to RichTextData // so block edit components receive the same types as locally-created blocks. if ( changes.blocks ) { changes.blocks = deserializeBlockAttributes( changes.blocks as Block[] ); } // When blocks changed but content didn't (the sender internally used a lazy // serializer function), inject a closure that captures the synced blocks // and serializes them on demand. Mirrors what useEntityBlockEditor does // locally. A fresh function on every persistent edit marks the entity // dirty (so the save button reactivates for peers), while serialization // stays lazy (only runs when getEditedPostContent reads it). The closure // captures `capturedBlocks` so the right content is returned even if the // caller later clears `record.blocks` (e.g. the Code Editor re-parsing // from content). if ( changes.blocks && ! changes.content ) { const capturedBlocks = changes.blocks; changes.content = () => __unstableSerializeAndClean( capturedBlocks as WPBlock[] ); } // Meta changes must be merged with the edited record since not all meta // properties are synced. if ( 'object' === typeof changes.meta ) { changes.meta = { ...editedRecord.meta, ...allowedMetaChanges, }; } // When remote content changes are detected, recalculate the local user's // selection using Y.RelativePosition to account for text shifts. The ydoc // has already been updated with remote content at this point, so converting // relative positions to absolute gives corrected offsets. Including the // selection in PostChanges ensures it dispatches atomically with content. const selectionHistory = getSelectionHistory( ydoc ); const shiftedSelection = getShiftedSelection( ydoc, selectionHistory ); if ( shiftedSelection ) { changes.selection = { ...shiftedSelection, initialPosition: 0, }; } return changes; } /** * This default sync config can be used for entities that are flat maps of * primitive values and do not require custom logic to merge changes. */ export const defaultSyncConfig: SyncConfig = { applyChangesToCRDTDoc: defaultApplyChangesToCRDTDoc, createAwareness: ( ydoc: CRDTDoc ) => new BaseAwareness( ydoc ), getChangesFromCRDTDoc: defaultGetChangesFromCRDTDoc, }; /** * This default collection sync config can be used to sync entity collections * (e.g., block comments) where we are not interested in merging changes at the * individual record level, but instead want to replace the entire collection * when changes are detected. */ export const defaultCollectionSyncConfig: SyncConfig = { applyChangesToCRDTDoc: () => {}, getChangesFromCRDTDoc: () => ( {} ), shouldSync: ( _: ObjectType, objectId: ObjectID | null ) => null === objectId, }; /** * Extract the raw string value from a property that may be a string or an object * with a `raw` property (`RenderedText`). * * @param {unknown} value The value to extract from. * @return {string|undefined} The raw string value, or undefined if it could not be determined. */ export function getRawValue( value?: unknown ): string | undefined { // Value may be a string property or a nested object with a `raw` property. if ( 'string' === typeof value ) { return value; } if ( value && 'object' === typeof value && 'raw' in value && 'string' === typeof value.raw ) { return value.raw; } return undefined; } function haveValuesChanged< ValueType >( currentValue: ValueType | undefined, newValue: ValueType | undefined ): boolean { return ! fastDeepEqual( currentValue, newValue ); } function updateMapValue< T extends YMapRecord, K extends keyof T >( map: YMapWrap< T >, key: K, currentValue: T[ K ] | undefined, newValue: T[ K ] | undefined ): void { if ( undefined === newValue ) { map.delete( key ); return; } if ( haveValuesChanged< T[ K ] >( currentValue, newValue ) ) { map.set( key, newValue ); } }