Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 33 additions & 43 deletions packages/core-data/src/utils/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,10 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
/**
* WordPress dependencies
*/
import { RichTextData } from '@wordpress/rich-text';
import { Y } from '@wordpress/sync';

// @ts-expect-error No exported types.
import { getBlockTypes } from '@wordpress/blocks';

/**
* Internal dependencies
*/
import type { WPBlockSelection } from '../types';
import { RichTextData } from '@wordpress/rich-text';
import { Y, Delta } from '@wordpress/sync';

/**
* Internal dependencies
Expand Down Expand Up @@ -192,13 +186,13 @@ function createNewYBlock( block: Block ): YBlock {
* 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, either from a peer or from the local editor.
* @param lastSelection The last cursor position, used for hinting the diff algorithm.
* @param incomingBlocks Gutenberg blocks being synced.
* @param cursorPosition The position of the cursor after the change occurs.
*/
export function mergeCrdtBlocks(
yblocks: YBlocks,
incomingBlocks: Block[],
lastSelection: WPBlockSelection | null
cursorPosition: number | null
): void {
// Ensure we are working with serializable block data.
if ( ! serializableBlocksCache.has( incomingBlocks ) ) {
Expand Down Expand Up @@ -315,7 +309,7 @@ export function mergeCrdtBlocks(
mergeRichTextUpdate(
currentAttribute,
attributeValue,
lastSelection
cursorPosition
);
} else {
currentAttributes.set(
Expand Down Expand Up @@ -351,7 +345,11 @@ export function mergeCrdtBlocks(
yblock.set( key, yInnerBlocks );
}

mergeCrdtBlocks( yInnerBlocks, value ?? [], lastSelection );
mergeCrdtBlocks(
yInnerBlocks,
value ?? [],
cursorPosition
);
break;
}

Expand Down Expand Up @@ -464,54 +462,46 @@ function isRichTextAttribute(
);
}

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 lastSelection The last cursor position before this update, used to hint the diff algorithm.
* @param blockYText The Y.Text to update.
* @param updatedValue The updated value.
* @param cursorPosition The position of the cursor after the change occurs.
*/
function mergeRichTextUpdate(
blockYText: Y.Text,
updatedValue: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
lastSelection: WPBlockSelection | null
cursorPosition: number | null
): void {
// TODO
// ====
// 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. However, it relies on a library
// (quill-delta) with a licensing issue that we are working to resolve.
//
// For now, we simply replace the full text content on each change.
//
// 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();
// }
// value, then apply it to the Y.Text.

// 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() );
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 deltaDiff = currentValueAsDelta.diff(
// updatedValueAsDelta,
// lastSelection?.offset
// );
const localYText = localDoc.getText( 'temporary-text' );
localYText.delete( 0, localYText.length );
localYText.insert( 0, updatedValue );

// blockYText.applyDelta( deltaDiff.ops );
const currentValueAsDelta = new Delta( blockYText.toDelta() );
const updatedValueAsDelta = new Delta( localYText.toDelta() );
const deltaDiff = currentValueAsDelta.diffWithCursor(
updatedValueAsDelta,
cursorPosition
);

blockYText.delete( 0, blockYText.toString().length );
blockYText.insert( 0, updatedValue );
blockYText.applyDelta( deltaDiff.ops );
}
17 changes: 7 additions & 10 deletions packages/core-data/src/utils/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
CRDT_RECORD_MAP_KEY,
WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
} from '../sync';
import type { WPBlockSelection, WPSelection } from '../types';
import type { WPSelection } from '../types';
import {
createYMap,
getRootMap,
Expand Down Expand Up @@ -62,9 +62,6 @@ export interface YPostRecord extends YMapRecord {
title: string;
}

// Hold a reference to the last known selection to help compute Y.Text deltas.
let lastSelection: WPBlockSelection | null = null;

// Properties that are allowed to be synced for a post.
const allowedPostProperties = new Set< string >( [
'author',
Expand Down Expand Up @@ -161,9 +158,14 @@ export function applyPostChangesToCRDTDoc(
// Block[] from local changes.
const newBlocks = ( newValue as PostChanges[ 'blocks' ] ) ?? [];

// 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, newBlocks, lastSelection );
mergeCrdtBlocks( currentBlocks, newBlocks, cursorPosition );
break;
}

Expand Down Expand Up @@ -238,11 +240,6 @@ export function applyPostChangesToCRDTDoc(
}
}
} );

// Update the lastSelection for use in computing Y.Text deltas.
if ( 'selection' in changes ) {
lastSelection = changes.selection?.selectionStart ?? null;
}
}

export function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData {
Expand Down
4 changes: 4 additions & 0 deletions packages/sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ Root-level key for the CRDT document that holds the entity record data.

The sync manager orchestrates the lifecycle of syncing entity records. It creates Yjs documents, connects to providers, creates awareness instances, and coordinates with the `core-data` store.

### Delta

Deltas are used to calculate incremental Y.Text updates.

### LOCAL_EDITOR_ORIGIN

Origin string for CRDT document changes originating from the local editor.
Expand Down
3 changes: 3 additions & 0 deletions packages/sync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,12 @@
"types": "build-types",
"sideEffects": false,
"dependencies": {
"@types/diff": "7.0.2",
"@wordpress/hooks": "file:../hooks",
"@wordpress/undo-manager": "file:../undo-manager",
"@wordpress/url": "file:../url",
"diff": "4.0.2",
"fast-deep-equal": "3.1.3",
"lib0": "^0.2.42",
"y-protocols": "^1.0.5",
"yjs": "~13.6.6"
Expand Down
5 changes: 5 additions & 0 deletions packages/sync/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
*/
export * as Y from 'yjs';

/**
* Deltas are used to calculate incremental Y.Text updates.
*/
export { default as Delta } from './quill-delta/Delta';

export {
CRDT_DOC_META_PERSISTENCE_KEY,
CRDT_RECORD_MAP_KEY,
Expand Down
126 changes: 126 additions & 0 deletions packages/sync/src/quill-delta/AttributeMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// File copied https://github.com/slab/delta/blob/main/src/AttributeMap.ts with changes:
// - lodash.clonedeep is replaced with JSON parse / stringify
// - lodash.isequal is replaced with fast-deep-equal.

/**
* External dependencies
*/
import { default as isEqual } from 'fast-deep-equal/es6';

function cloneDeep< T >( value: T ): T {
return JSON.parse( JSON.stringify( value ) ) as T;
}

interface AttributeMap {
[ key: string ]: unknown;
}

namespace AttributeMap {
export function compose(
a: AttributeMap = {},
b: AttributeMap = {},
keepNull = false
): AttributeMap | undefined {
if ( typeof a !== 'object' ) {
a = {};
}
if ( typeof b !== 'object' ) {
b = {};
}
let attributes = cloneDeep( b );
if ( ! keepNull ) {
attributes = Object.keys( attributes ).reduce< AttributeMap >(
( copy, key ) => {
if (
attributes[ key ] !== null ||
attributes[ key ] !== undefined
) {
copy[ key ] = attributes[ key ];
}
return copy;
},
{}
);
}
for ( const key in a ) {
if ( a[ key ] !== undefined && b[ key ] === undefined ) {
attributes[ key ] = a[ key ];
}
}
return Object.keys( attributes ).length > 0 ? attributes : undefined;
}

export function diff(
a: AttributeMap = {},
b: AttributeMap = {}
): AttributeMap | undefined {
if ( typeof a !== 'object' ) {
a = {};
}
if ( typeof b !== 'object' ) {
b = {};
}
const attributes = Object.keys( a )
.concat( Object.keys( b ) )
.reduce< AttributeMap >( ( attrs, key ) => {
if ( ! isEqual( a[ key ], b[ key ] ) ) {
attrs[ key ] = b[ key ] === undefined ? null : b[ key ];
}
return attrs;
}, {} );
return Object.keys( attributes ).length > 0 ? attributes : undefined;
}

export function invert(
attr: AttributeMap = {},
base: AttributeMap = {}
): AttributeMap {
attr = attr || {};
const baseInverted = Object.keys( base ).reduce< AttributeMap >(
( memo, key ) => {
if (
base[ key ] !== attr[ key ] &&
attr[ key ] !== undefined
) {
memo[ key ] = base[ key ];
}
return memo;
},
{}
);
return Object.keys( attr ).reduce< AttributeMap >( ( memo, key ) => {
if ( attr[ key ] !== base[ key ] && base[ key ] === undefined ) {
memo[ key ] = null;
}
return memo;
}, baseInverted );
}

export function transform(
a: AttributeMap | undefined,
b: AttributeMap | undefined,
priority = false
): AttributeMap | undefined {
if ( typeof a !== 'object' ) {
return b;
}
if ( typeof b !== 'object' ) {
return undefined;
}
if ( ! priority ) {
return b; // b simply overwrites us without priority
}
const attributes = Object.keys( b ).reduce< AttributeMap >(
( attrs, key ) => {
if ( a[ key ] === undefined ) {
attrs[ key ] = b[ key ]; // null is a valid value
}
return attrs;
},
{}
);
return Object.keys( attributes ).length > 0 ? attributes : undefined;
}
}

export default AttributeMap;
Loading
Loading