Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Draft: Sync Editor React state with Yjs #18357

Closed
wants to merge 2 commits into from
Closed
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
50 changes: 47 additions & 3 deletions package-lock.json

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

5 changes: 4 additions & 1 deletion packages/core-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
"@wordpress/url": "file:../url",
"equivalent-key-map": "^0.2.2",
"lib0": "^0.1.2",
"lodash": "^4.17.15",
"rememo": "^3.0.0"
"rememo": "^3.0.0",
"y-websocket": "^1.0.6",
"yjs": "^13.0.0-102"
},
"publishConfig": {
"access": "public"
Expand Down
15 changes: 14 additions & 1 deletion packages/core-data/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
* External dependencies
*/
import { castArray, get, isEqual, find } from 'lodash';
/**
* Internal dependencies
*/
import * as ystore from './ystore';

/**
* Internal dependencies
Expand Down Expand Up @@ -82,7 +86,11 @@ export function receiveEntityRecords( kind, name, records, query, invalidateCach
} else {
action = receiveItems( records );
}

for ( const record of castArray( records ) ) {
if ( record.id ) {
ystore.getInstance( record.id, kind, name );
}
}
return {
...action,
kind,
Expand Down Expand Up @@ -149,6 +157,11 @@ export function* editEntityRecord( kind, name, recordId, edits, options = {} ) {
recordId
);

if ( ! options.syncIgnore && edits.blocks ) {
// sync edits
ystore.updateInstance( recordId, kind, name, edits.blocks );
}

const edit = {
kind,
name,
Expand Down
141 changes: 141 additions & 0 deletions packages/core-data/src/ystore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* WordPress dependencies
*/
import { dispatch } from '@wordpress/data';

/**
* External dependencies
*/
import * as map from 'lib0/map.js';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import _ from 'lodash';

/**
* Map of Yjs instances
*/
const instances = new Map();

/**
* If two clients join a session at the same time, both with fill the shared
* document with the initial data, resulting in duplicate blocks.
* Before transforming the Yjs document to a Gutenberg blocks, we remove all
* duplicate blocks.
*
* @param {Y.Array} ycontent
*/
const checkDuplicateBlocks = ( ycontent ) => {
const found = new Set();
for ( let i = ycontent.length - 1; i >= 0; i-- ) {
const c = ycontent.get( i );
const clientId = c.clientId;
if ( clientId ) {
if ( found.has( clientId ) ) {
ycontent.delete( i, 1 );
} else {
found.add( c.clientId );
}
}
}
};

const gutenbergSourcedChange = Symbol( 'gutenberg-sourced-change' );

/**
* @param {number} id document id
* @param {string} kind Kind of the edited entity record.
* @param {string} name Name of the edited entity record.
* @return {{ydoc:Y.Doc,provider:any,type:Y.Array}} Instance description
*/
export const getInstance = ( id, kind, name ) => map.setIfUndefined( instances, id, () => {
// ydoc is the CRDT state
const ydoc = new Y.Doc();
// Connect ydoc to other clients via the Websocket provider.
const location = window.location;
const provider = new WebsocketProvider( `${ location.protocol === 'http:' ? 'ws:' : 'wss:' }//yjs-demos.now.sh`, `gutenberg-${ location.host }-${ id }`, ydoc );
// Define a shared type on ydoc.
// A shared type works like any other data type - but it fires synchronous
// events when it changes and is automatically synced with other clients.
const type = ydoc.getArray( 'gutenberg-blocks' );
provider.on( 'sync', () => {
const updateEditor = () => {
checkDuplicateBlocks( type );
// Type is synced and has content ⇒ Overwrite current state.
dispatch( 'core' ).editEntityRecord(
kind,
name,
id,
{ blocks: type.toArray() },
{ undoIgnore: true, syncIgnore: true }
);
};
type.observeDeep( ( event, transaction ) => {
if ( transaction.origin !== gutenbergSourcedChange ) {
updateEditor();
}
} );
if ( type.length > 0 ) {
updateEditor();
} else {
// Type is synced and has no content yet.
dispatch( 'core' ).receiveEntityRecords( kind, name, id ).then( ( record ) => {
if ( type.length === 0 && record.blocks ) {
type.insert( 0, record.blocks );
}
} );
}
} );

return {
ydoc, provider, type,
};
} );

/**
* Create a diff between two editor states.
*
* @template T
* @param {Array<T>} a The old state
* @param {Array<T>} b The updated state
* @return {{index:number,remove:number,insert:Array<T>}} The diff description.
*/
const simpleDiff = ( a, b ) => {
let left = 0; // number of same characters counting from left
let right = 0; // number of same characters counting from right
while ( left < a.length && left < b.length && _.isEqual( a[ left ], b[ left ] ) ) {
left++;
}
if ( left !== a.length || left !== b.length ) {
// Only check right if a !== b
while ( right + left < a.length && right + left < b.length && _.isEqual( a[ a.length - right - 1 ], b[ b.length - right - 1 ] ) ) {
right++;
}
}
return {
index: left,
remove: a.length - left - right,
insert: b.slice( left, b.length - right ),
};
};

/**
* @param {number} id document id
* @param {string} kind Kind of the edited entity record.
* @param {string} name Name of the edited entity record.
* @param {Array} newEditorContent
*/
export const updateInstance = ( id, kind, name, newEditorContent ) => {
const { type, provider, ydoc } = getInstance( id, kind, name );
if ( provider.synced ) {
// Use a very basic diff approach to calculate the differences
const currentContent = type.toArray();
const d = simpleDiff( currentContent, newEditorContent );
// Bundle all changes as a single transaction
// This transaction will trigger the observer call, which will
// trigger updateBlocks.
ydoc.transact( () => {
type.delete( d.index, d.remove );
type.insert( d.index, d.insert );
}, gutenbergSourcedChange );
}
};