Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
48 changes: 32 additions & 16 deletions package-lock.json

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

1 change: 0 additions & 1 deletion packages/core-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
"fast-deep-equal": "^3.1.3",
"lib0": "^0.2.99",
"memize": "^2.1.0",
"quill-delta": "5.1.0",
"uuid": "^9.0.1"
},
"peerDependencies": {
Expand Down
36 changes: 7 additions & 29 deletions packages/core-data/src/utils/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,16 @@
import { v4 as uuidv4 } from 'uuid';
import * as math from 'lib0/math';
import * as fun from 'lib0/function';
import Delta from 'quill-delta';

/**
* WordPress dependencies
*/
import { RichTextData } from '@wordpress/rich-text';
import { Y } from '@wordpress/sync';
import { Y, Delta } from '@wordpress/sync';

// @ts-expect-error - This is a TypeScript file, and @wordpress/blocks doesn't have a tsconfig.json?
import { getBlockTypes } from '@wordpress/blocks';

/**
* Internal dependencies
*/
import type { WPBlockSelection } from '../types';

interface BlockAttributes {
[ key: string ]: unknown;
}
Expand Down Expand Up @@ -190,13 +184,11 @@ function createNewYBlock( block: Block ): YBlock {
*
* @param yblocks The blocks in the local Y.Doc.
* @param incomingBlocks Gutenberg blocks being synced.
* @param lastSelection
* @param _origin The origin of the sync, either 'syncProvider' or 'gutenberg'.
*/
export function mergeCrdtBlocks(
yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc
incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor
lastSelection: WPBlockSelection | null, // Last cursor position, used for hinting the diff algorithm
_origin: string // eslint-disable-line @typescript-eslint/no-unused-vars
): void {
// Ensure we are working with serializable block data.
Expand Down Expand Up @@ -312,8 +304,7 @@ export function mergeCrdtBlocks(

mergeRichTextUpdate(
blockYText,
attributeValue,
lastSelection
attributeValue
);
} else {
currentAttributes.set(
Expand Down Expand Up @@ -343,12 +334,7 @@ export function mergeCrdtBlocks(
case 'innerBlocks': {
// Recursively merge innerBlocks
const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >;
mergeCrdtBlocks(
yInnerBlocks,
value ?? [],
lastSelection,
_origin
);
mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin );
break;
}

Expand Down Expand Up @@ -465,15 +451,10 @@ let localDoc: Y.Doc | null = null;
* 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.
*/
function mergeRichTextUpdate(
blockYText: Y.Text,
updatedValue: string,
lastSelection: WPBlockSelection | null
): void {
function mergeRichTextUpdate( blockYText: Y.Text, updatedValue: string ): void {
const doc = blockYText.doc;

if ( ! doc ) {
Expand All @@ -493,10 +474,7 @@ function mergeRichTextUpdate(
const currentValueAsDelta = new Delta( blockYText.toDelta() );
const updatedValueAsDelta = new Delta( localYText.toDelta() );

const deltaDiff = currentValueAsDelta.diff(
updatedValueAsDelta,
lastSelection?.offset
);
const deltaDiff = currentValueAsDelta.diff( updatedValueAsDelta );

blockYText.applyDelta( deltaDiff.ops );
}
16 changes: 2 additions & 14 deletions packages/core-data/src/utils/crdt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@ import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync';
import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks';
import { type Post } from '../entity-types/post';
import { type Type } from '../entity-types';
import type { WPBlockSelection, WPSelection } from '../types';
import type { WPSelection } from '../types';

type PostChanges = Partial< Post > & {
blocks?: Block[];
selection?: WPSelection;
};

let lastSelection: WPBlockSelection | null = null;

/**
* Given a set of local changes to a post record, apply those changes to the
* local Y.Doc.
Expand Down Expand Up @@ -78,12 +76,7 @@ export function applyPostChangesToCRDTDoc(

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

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

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

/**
Expand Down
108 changes: 104 additions & 4 deletions packages/core-data/src/utils/test/crdt-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ jest.mock( '@wordpress/rich-text', () => {
class MockRichTextData {
private text: string = '';

constructor( text?: string ) {
if ( text ) {
this.text = text;
}
}

toString() {
return this.text;
}
Expand Down Expand Up @@ -106,14 +112,33 @@ jest.mock( '@wordpress/blocks', () => ( {
* Mock @wordpress/sync - Yjs implementation
*/
jest.mock( '@wordpress/sync', () => {
class MockYDoc {
private texts: Map< string, any > = new Map();

getText( name: string ): any {
if ( ! this.texts.has( name ) ) {
this.texts.set( name, new MockYText( '', this ) );
}
return this.texts.get( name );
}
}

class MockYMap {
private data: Map< string, any > = new Map();
public doc: MockYDoc | null = null;

constructor( entries?: Array< [ string, any ] > ) {
// Create a doc for this map
this.doc = new MockYDoc();

if ( entries ) {
entries.forEach( ( [ key, value ] ) =>
this.data.set( key, value )
);
entries.forEach( ( [ key, value ] ) => {
// If the value is a Y.Text, attach the doc to it
if ( value instanceof MockYText && ! value.doc ) {
value.doc = this.doc;
}
this.data.set( key, value );
} );
}
}

Expand All @@ -122,6 +147,10 @@ jest.mock( '@wordpress/sync', () => {
}

set( key: string, value: any ): void {
// If the value is a Y.Text, attach the doc to it
if ( value instanceof MockYText && ! value.doc ) {
value.doc = this.doc;
}
this.data.set( key, value );
}

Expand Down Expand Up @@ -189,9 +218,15 @@ jest.mock( '@wordpress/sync', () => {

class MockYText {
private text: string = '';
public doc: MockYDoc | null = null;

constructor( text: string = '' ) {
constructor( text: string = '', doc: MockYDoc | null = null ) {
this.text = text;
this.doc = doc;
}

get length(): number {
return this.text.length;
}

toString(): string {
Expand All @@ -208,17 +243,82 @@ jest.mock( '@wordpress/sync', () => {
this.text.slice( 0, index ) + this.text.slice( index + length );
}

toDelta(): Array< {
insert?: string;
delete?: number;
retain?: number;
} > {
return [ { insert: this.text } ];
}

applyDelta(
ops: Array< { insert?: string; delete?: number; retain?: number } >
): void {
let index = 0;
for ( const op of ops ) {
if ( op.retain ) {
index += op.retain;
} else if ( op.insert ) {
this.insert( index, op.insert );
index += op.insert.length;
} else if ( op.delete ) {
this.delete( index, op.delete );
}
}
}

toJSON(): string {
return this.text;
}
}

class MockDelta {
public ops: Array< {
insert?: string;
delete?: number;
retain?: number;
} > = [];

constructor(
ops?: Array< { insert?: string; delete?: number; retain?: number } >
) {
this.ops = ops || [];
}

diff( other: MockDelta ): MockDelta {
// Simple diff implementation for testing
const currentText = this.ops
.map( ( op ) => op.insert || '' )
.join( '' );
const otherText = other.ops
.map( ( op ) => op.insert || '' )
.join( '' );

if ( currentText === otherText ) {
return new MockDelta( [] );
}

// Simple replace operation
const deltaOps = [];
if ( currentText.length > 0 ) {
deltaOps.push( { delete: currentText.length } );
}
if ( otherText.length > 0 ) {
deltaOps.push( { insert: otherText } );
}

return new MockDelta( deltaOps );
}
}

return {
Y: {
Doc: MockYDoc,
Map: MockYMap,
Array: MockYArray,
Text: MockYText,
},
Delta: MockDelta,
};
} );

Expand Down
Loading
Loading