@wordpress/core-data
Version:
Access to and manipulation of core WordPress entities.
479 lines (418 loc) • 12.7 kB
text/typescript
/**
* External dependencies
*/
import fastDeepEqual from 'fast-deep-equal/es6/index.js';
/**
* WordPress dependencies
*/
// @ts-expect-error No exported types.
import { __unstableSerializeAndClean } from '@wordpress/blocks';
import {
type CRDTDoc,
type ObjectData,
type SyncConfig,
Y,
} from '@wordpress/sync';
/**
* Internal dependencies
*/
import { BaseAwareness } from '../awareness/base-awareness';
import {
mergeCrdtBlocks,
mergeRichTextUpdate,
type Block,
type YBlock,
type YBlocks,
} from './crdt-blocks';
import { type Post } from '../entity-types/post';
import { type Type } from '../entity-types';
import {
CRDT_DOC_META_PERSISTENCE_KEY,
CRDT_RECORD_MAP_KEY,
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
} from '../sync';
import type { WPSelection } from '../types';
import { updateSelectionHistory } from './crdt-selection';
import {
createYMap,
getRootMap,
isYMap,
type YMapRecord,
type YMapWrap,
} from './crdt-utils';
// Changes that can be applied to a post entity record.
export type PostChanges = Partial< Post > & {
blocks?: Block[];
content?: Post[ 'content' ] | string;
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;
}
// Properties that are allowed to be synced for a post.
const allowedPostProperties = new Set< string >( [
'author',
'blocks',
'content',
'categories',
'comment_status',
'date',
'excerpt',
'featured_media',
'format',
'meta',
'ping_status',
'slug',
'status',
'sticky',
'tags',
'template',
'title',
] );
// Post meta keys that should *not* be synced.
const disallowedPostMetaKeys = new Set< string >( [
WORDPRESS_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 {Type} _postType
* @return {void}
*/
export function applyPostChangesToCRDTDoc(
ydoc: CRDTDoc,
changes: PostChanges,
_postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
): void {
const ymap = getRootMap< YPostRecord >( ydoc, CRDT_RECORD_MAP_KEY );
Object.keys( changes ).forEach( ( key ) => {
if ( ! allowedPostProperties.has( key ) ) {
return;
}
const newValue = changes[ key ];
// Cannot serialize function values, so cannot sync them.
if ( 'function' === typeof newValue ) {
return;
}
switch ( key ) {
case 'blocks': {
// Blocks are undefined when they need to be re-parsed from content.
if ( ! newValue ) {
// 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 );
}
// Block changes from typing are bundled with a 'selection' update.
// Pass the resulting cursor position to the mergeCrdtBlocks function.
const cursorPosition =
changes.selection?.selectionStart?.offset ?? null;
// Merge blocks does not need `setValue` because it is operating on a
// Yjs type that is already in the Y.Doc.
mergeCrdtBlocks( currentBlocks, newValue, cursorPosition );
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 );
}
}
function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
return getRootMap( crdtDoc, CRDT_RECORD_MAP_KEY ).toJSON();
}
/**
* 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 {Type} _postType
* @return {Partial<PostChanges>} The changes that should be applied to the local record.
*/
export function getPostChangesFromCRDTDoc(
ydoc: CRDTDoc,
editedRecord: Post,
_postType: Type // eslint-disable-line @typescript-eslint/no-unused-vars
): 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 ( ! allowedPostProperties.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 )
);
}
// The consumers of blocks have memoization that renders optimization
// here unnecessary.
return true;
}
case 'date': {
// Do not overwrite a "floating" date. Borrowing logic from the
// isEditedPostDateFloating selector.
const currentDateIsFloating =
[ 'draft', 'auto-draft', 'pending' ].includes(
ymap.get( 'status' ) as string
) &&
( null === currentValue ||
editedRecord.modified === currentValue );
if ( currentDateIsFloating ) {
return false;
}
return haveValuesChanged( currentValue, newValue );
}
case 'meta': {
allowedMetaChanges = Object.fromEntries(
Object.entries( newValue ?? {} ).filter(
( [ metaKey ] ) =>
! disallowedPostMetaKeys.has( metaKey )
)
);
// Merge the allowed meta changes with the current meta values since
// not all meta properties are synced.
const mergedValue = {
...( currentValue as PostChanges[ 'meta' ] ),
...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 );
}
}
} )
);
// 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,
};
}
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,
};
/**
* 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.
*/
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 );
}
}