@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
609 lines (536 loc) • 18.3 kB
text/typescript
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 );
}
}