diff --git a/lib/experimental/synchronization.php b/lib/experimental/synchronization.php index 87f13a7c685a59..a7e5beada875c0 100644 --- a/lib/experimental/synchronization.php +++ b/lib/experimental/synchronization.php @@ -22,3 +22,20 @@ function gutenberg_rest_api_init_collaborative_editing() { wp_add_inline_script( 'wp-sync', 'window.__experimentalCollaborativeEditingSecret = "' . $collaborative_editing_secret . '";', 'before' ); } add_action( 'admin_init', 'gutenberg_rest_api_init_collaborative_editing' ); + +/** + * Add support for collaborative editing to some built-in post types. + */ +function gutenberg_add_collaborative_editing_post_type_support() { + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + if ( ! $gutenberg_experiments || ! array_key_exists( 'gutenberg-sync-collaboration', $gutenberg_experiments ) ) { + return; + } + + foreach ( array( 'page', 'post' ) as $post_type ) { + if ( post_type_exists( $post_type ) ) { + add_post_type_support( $post_type, 'collaborative-editing' ); + } + } +} +add_action( 'init', 'gutenberg_add_collaborative_editing_post_type_support', 10, 0 ); diff --git a/lib/experiments-page.php b/lib/experiments-page.php index fe4ce4a2cc756f..6f5103a3d3cf49 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -117,12 +117,12 @@ function gutenberg_initialize_experiments_settings() { add_settings_field( 'gutenberg-sync-collaboration', - __( 'Collaboration: add real time editing', 'gutenberg' ), + __( 'Collaboration: enable real-time collaboration', 'gutenberg' ), 'gutenberg_display_experiment_field', 'gutenberg-experiments', 'gutenberg_experiments_section', array( - 'label' => __( 'Enables live collaboration and offline persistence between peers.', 'gutenberg' ), + 'label' => __( 'Enables real-time collaboration between peers.', 'gutenberg' ), 'id' => 'gutenberg-sync-collaboration', ) ); diff --git a/package-lock.json b/package-lock.json index ed5907b56650ab..26d80906c29610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27568,11 +27568,6 @@ "node": ">=8" } }, - "node_modules/import-locals": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/import-locals/-/import-locals-2.0.0.tgz", - "integrity": "sha512-1/bPE89IZhyf7dr5Pkz7b4UyVXy5pEt7PTEfye15UEn3AK8+2zwcDCfKk9Pwun4ltfhOSszOrReSsFcDKw/yoA==" - }, "node_modules/import-meta-resolve": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-3.1.1.tgz", @@ -31125,25 +31120,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lib0": { - "version": "0.2.79", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.79.tgz", - "integrity": "sha512-fIdPbxzMVq10wt3ou1lp3/f9n5ciHZ6t+P1vyGy3XXr018AntTYM4eg24sNFcNq8SYDQwmhhoGdS58IlYBzfBw==", - "dependencies": { - "isomorphic.js": "^0.2.4" - }, - "bin": { - "0gentesthtml": "bin/gentesthtml.js", - "0serve": "bin/0serve.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/libnpmaccess": { "version": "8.0.6", "resolved": "https://registry.npmjs.org/libnpmaccess/-/libnpmaccess-8.0.6.tgz", @@ -48482,56 +48458,6 @@ "node": ">=0.4" } }, - "node_modules/y-indexeddb": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.11.tgz", - "integrity": "sha512-HOKQ70qW1h2WJGtOKu9rE8fbX86ExVZedecndMuhwax3yM4DQsQzCTGHt/jvTrFZr/9Ahvd8neD6aZ4dMMjtdg==", - "dependencies": { - "lib0": "^0.2.74" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "peerDependencies": { - "yjs": "^13.0.0" - } - }, - "node_modules/y-protocols": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.5.tgz", - "integrity": "sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==", - "dependencies": { - "lib0": "^0.2.42" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, - "node_modules/y-webrtc": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.2.5.tgz", - "integrity": "sha512-ZyBNvTI5L28sQ2PQI0T/JvyWgvuTq05L21vGkIlcvNLNSJqAaLCBJRe3FHEqXoaogqWmRcEAKGfII4ErNXMnNw==", - "dependencies": { - "lib0": "^0.2.42", - "simple-peer": "^9.11.0", - "y-protocols": "^1.0.5" - }, - "bin": { - "y-webrtc-signaling": "bin/server.js" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - }, - "optionalDependencies": { - "ws": "^7.2.0" - } - }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -48664,22 +48590,6 @@ "fd-slicer": "~1.1.0" } }, - "node_modules/yjs": { - "version": "13.6.7", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.7.tgz", - "integrity": "sha512-mCZTh4kjvUS2DnaktsYN6wLH3WZCJBLqrTdkWh1bIDpA/sB/GNFaLA/dyVJj2Hc7KwONuuoC/vWe9bwBBosZLQ==", - "dependencies": { - "lib0": "^0.2.74" - }, - "engines": { - "node": ">=16.0.0", - "npm": ">=8.0.0" - }, - "funding": { - "type": "GitHub Sponsors ❤", - "url": "https://github.com/sponsors/dmonad" - } - }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -50230,6 +50140,7 @@ "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", @@ -50242,6 +50153,7 @@ "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", "fast-deep-equal": "^3.1.3", + "lib0": "^0.2.99", "memize": "^2.1.0", "uuid": "^9.0.1" }, @@ -50254,6 +50166,27 @@ "react-dom": "^18.0.0" } }, + "packages/core-data/node_modules/lib0": { + "version": "0.2.114", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "packages/create-block": { "name": "@wordpress/create-block", "version": "4.75.0", @@ -52174,22 +52107,97 @@ "version": "1.32.0", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", "@wordpress/url": "file:../url", - "import-locals": "^2.0.0", - "lib0": "^0.2.42", + "lib0": "^0.2.99", "simple-peer": "^9.11.0", - "y-indexeddb": "~9.0.11", - "y-protocols": "^1.0.5", - "y-webrtc": "~10.2.5", - "yjs": "~13.6.6" + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "13.6.27" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, + "packages/sync/node_modules/lib0": { + "version": "0.2.114", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", + "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, + "packages/sync/node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "packages/sync/node_modules/y-protocols": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", + "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "packages/sync/node_modules/yjs": { + "version": "13.6.27", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", + "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "packages/token-list": { "name": "@wordpress/token-list", "version": "3.32.0", diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index 3cc2b21b141e67..04269735229e29 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -104,15 +104,25 @@ export default function useBlockSync( { // and so it would already be persisted. __unstableMarkNextChangeAsNotPersistent(); if ( clientId ) { + const blockName = getBlockName( clientId ); + const isPostContentBlock = blockName === 'core/post-content'; + // It is important to batch here because otherwise, // as soon as `setHasControlledInnerBlocks` is called // the effect to restore might be triggered // before the actual blocks get set properly in state. registry.batch( () => { setHasControlledInnerBlocks( clientId, true ); - const storeBlocks = controlledBlocks.map( ( block ) => - cloneBlock( block ) - ); + + // For post-content block children, preserve the + // original blocks to maintain UUIDs used for + // multi-user collaboration + // + // Unsure: Why are these blocks being cloned? Do they need to be? + const storeBlocks = isPostContentBlock + ? controlledBlocks + : controlledBlocks.map( ( block ) => cloneBlock( block ) ); + if ( subscribedRef.current ) { pendingChangesRef.current.incoming = storeBlocks; } diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 62d57bca419df9..283e8b654ce671 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -40,6 +40,7 @@ "@wordpress/data": "file:../data", "@wordpress/deprecated": "file:../deprecated", "@wordpress/element": "file:../element", + "@wordpress/hooks": "file:../hooks", "@wordpress/html-entities": "file:../html-entities", "@wordpress/i18n": "file:../i18n", "@wordpress/is-shallow-equal": "file:../is-shallow-equal", @@ -52,6 +53,7 @@ "change-case": "^4.1.2", "equivalent-key-map": "^0.2.2", "fast-deep-equal": "^3.1.3", + "lib0": "^0.2.99", "memize": "^2.1.0", "uuid": "^9.0.1" }, diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index c04f40bb68fba7..465bc755e9601c 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -374,7 +374,7 @@ export const deleteEntityRecord = */ export const editEntityRecord = ( kind, name, recordId, edits, options = {} ) => - ( { select, dispatch } ) => { + async ( { select, dispatch } ) => { logEntityDeprecation( kind, name, 'editEntityRecord' ); const entityConfig = select.getEntityConfig( kind, name ); if ( ! entityConfig ) { @@ -408,41 +408,38 @@ export const editEntityRecord = return acc; }, {} ), }; - if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - const objectId = entityConfig.getSyncObjectId( recordId ); - getSyncProvider().update( - entityConfig.syncObjectType + '--edit', - objectId, - edit.edits - ); - } - } else { - if ( ! options.undoIgnore ) { - select.getUndoManager().addRecord( - [ - { - id: { kind, name, recordId }, - changes: Object.keys( edits ).reduce( - ( acc, key ) => { - acc[ key ] = { - from: editedRecord[ key ], - to: edits[ key ], - }; - return acc; - }, - {} - ), - }, - ], - options.isCached - ); - } - dispatch( { - type: 'EDIT_ENTITY_RECORD', - ...edit, - } ); + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled + ) { + getSyncProvider().updateCRDTDoc( + entityConfig.syncConfig, + record, + edit.edits, + 'gutenberg' + ); + } + if ( ! options.undoIgnore ) { + select.getUndoManager().addRecord( + [ + { + id: { kind, name, recordId }, + changes: Object.keys( edits ).reduce( ( acc, key ) => { + acc[ key ] = { + from: editedRecord[ key ], + to: edits[ key ], + }; + return acc; + }, {} ), + }, + ], + options.isCached + ); } + dispatch( { + type: 'EDIT_ENTITY_RECORD', + ...edit, + } ); }; /** @@ -732,6 +729,20 @@ export const saveEntityRecord = if ( name === 'wp_template' && persistedRecord ) { edits.status = 'publish'; } + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled && + entityConfig.syncConfig?.supports?.crdtPersistence + ) { + // Allow sync provider to create meta for the entity before persisting. + edits.meta = { + ...edits.meta, + ...( await getSyncProvider().createEntityMeta( + entityConfig.syncConfig, + { ...persistedRecord, ...edits } + ) ), + }; + } updatedRecord = await __unstableFetch( { path, method: recordId ? 'PUT' : 'POST', @@ -745,6 +756,16 @@ export const saveEntityRecord = true, edits ); + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled && + entityConfig.syncConfig?.supports?.crdtPersistence + ) { + getSyncProvider().updateLastPersistedDate( + entityConfig.syncConfig, + persistedRecord + ); + } } } catch ( _error ) { hasError = true; diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 14ec31b3a86240..bd66524a6761dd 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -8,7 +8,16 @@ import { capitalCase, pascalCase } from 'change-case'; */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { RichTextData } from '@wordpress/rich-text'; + +/** + * Internal dependencies + */ +import { + applyPostChangesToCRDTDoc, + getInitialPostObjectData, + getPostChangesFromCRDTDoc, + getSyncedPropertiesForPostType, +} from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; @@ -40,24 +49,6 @@ export const rootEntitiesConfig = [ // The entity doesn't support selecting multiple records. // The property is maintained for backward compatibility. plural: '__unstableBases', - syncConfig: { - fetch: async () => { - return apiFetch( { path: '/' } ); - }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); - }, - }, - syncObjectType: 'root/base', - getSyncObjectId: () => 'index', }, { label: __( 'Post Type' ), @@ -67,26 +58,6 @@ export const rootEntitiesConfig = [ baseURL: '/wp/v2/types', baseURLParams: { context: 'edit' }, plural: 'postTypes', - syncConfig: { - fetch: async ( id ) => { - return apiFetch( { - path: `/wp/v2/types/${ id }?context=edit`, - } ); - }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); - }, - }, - syncObjectType: 'root/postType', - getSyncObjectId: ( id ) => id, }, { name: 'media', @@ -276,29 +247,6 @@ export const prePersistPostType = ( persistedRecord, edits ) => { return newEdits; }; -const serialisableBlocksCache = new WeakMap(); - -function makeBlockAttributesSerializable( attributes ) { - const newAttributes = { ...attributes }; - for ( const [ key, value ] of Object.entries( attributes ) ) { - if ( value instanceof RichTextData ) { - newAttributes[ key ] = value.valueOf(); - } - } - return newAttributes; -} - -function makeBlocksSerializable( blocks ) { - return blocks.map( ( block ) => { - const { innerBlocks, attributes, ...rest } = block; - return { - ...rest, - attributes: makeBlockAttributesSerializable( attributes ), - innerBlocks: makeBlocksSerializable( innerBlocks ), - }; - } ); -} - /** * Returns the list of post type entities. * @@ -306,13 +254,15 @@ function makeBlocksSerializable( blocks ) { */ async function loadPostTypeEntities() { const postTypes = await apiFetch( { - path: '/wp/v2/types?context=view', + path: '/wp/v2/types?context=edit', } ); return Object.entries( postTypes ?? {} ).map( ( [ name, postType ] ) => { const isTemplate = [ 'wp_template', 'wp_template_part' ].includes( name ); const namespace = postType?.rest_namespace ?? 'wp/v2'; + const syncedProperties = getSyncedPropertiesForPostType( postType ); + return { kind: 'postType', baseURL: `/${ namespace }/${ postType.rest_base }`, @@ -334,39 +284,106 @@ async function loadPostTypeEntities() { __unstablePrePersist: isTemplate ? undefined : prePersistPostType, __unstable_rest_base: postType.rest_base, syncConfig: { - fetch: async ( id ) => { - return apiFetch( { - path: `/${ namespace }/${ postType.rest_base }/${ id }?context=edit`, - } ); + /** + * Is syncing enabled for this entity? + * + * @type {boolean} + */ + enabled: Boolean( + postType.supports?.[ 'collaborative-editing' ] && + postType.supports?.editor + ), + + /** + * Apply changes from the local editor to the local CRDT document so + * that those changes can be synced to other peers (via the provider). + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {Partial< import('@wordpress/sync').ObjectData >} changes + * @param {import('@wordpress/sync').ObjectData} record + * @param {string} origin + * @return {void} + */ + applyChangesToCRDTDoc: ( crdtDoc, changes, record, origin ) => { + applyPostChangesToCRDTDoc( + crdtDoc, + changes, + record, + postType, + syncedProperties, + origin + ); }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( typeof value !== 'function' ) { - if ( key === 'blocks' ) { - if ( ! serialisableBlocksCache.has( value ) ) { - serialisableBlocksCache.set( - value, - makeBlocksSerializable( value ) - ); - } + /** + * Extract changes from a CRDT document that can be used to update the + * local editor state. + * + * @param {import('@wordpress/sync').CRDTDoc} crdtDoc + * @param {import('@wordpress/sync').ObjectData} record + * @return {Partial< import('@wordpress/sync').ObjectData >} Changes to record + */ + getChangesFromCRDTDoc: ( crdtDoc, record ) => + getPostChangesFromCRDTDoc( + crdtDoc, + record, + postType, + syncedProperties + ), - value = serialisableBlocksCache.get( value ); - } + /** + * This initial object data represents the data that will be synced via + * the CRDT document, which may differ from the entity record. There may + * be properties that should not be synced, or properties that are + * derived from the record. + * + * @param {import('@wordpress/sync').ObjectData} record + * @return {import('@wordpress/sync').ObjectData} The initial data + */ + getInitialObjectData: ( record ) => + getInitialPostObjectData( + record, + postType, + syncedProperties + ), - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + /** + * Get the immutable identifier for an entity record. + * + * @param {import('@wordpress/sync').ObjectData} record + * @return {import('@wordpress/sync').ObjectID} The entity's ID + */ + getObjectId: ( { id } ) => id, + + /** + * The object type for the entity, used to scope CRDT documents. + * + * @type {import('@wordpress/sync').ObjectType} + */ + objectType: `postType/${ postType.slug }`, + + /** + * Sync features supported by the entity. Since overall syncing support + * is gated by the `enabled` property, we don't need to check for + * "editor" support here. + * + * @type {Record< string, boolean >} + */ + supports: { + awareness: true, + crdtPersistence: Boolean( + postType.supports?.[ 'custom-fields' ] + ), + undo: true, }, + + /** + * The properties that should be synced via the CRDT document. + * + * @type {Set< string >} + */ + syncedProperties, }, - syncObjectType: 'postType/' + postType.name, - getSyncObjectId: ( id ) => id, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => `/${ namespace }/${ @@ -413,24 +430,6 @@ async function loadSiteEntity() { name: 'site', kind: 'root', baseURL: '/wp/v2/settings', - syncConfig: { - fetch: async () => { - return apiFetch( { path: '/wp/v2/settings' } ); - }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); - }, - }, - syncObjectType: 'root/site', - getSyncObjectId: () => 'index', meta: {}, }; diff --git a/packages/core-data/src/private-selectors.ts b/packages/core-data/src/private-selectors.ts index 0b3e67e7ce3eed..86cbd2749e1e91 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -10,6 +10,7 @@ import { getDefaultTemplateId, getEntityRecord, type State } from './selectors'; import { STORE_NAME } from './name'; import { unlock } from './lock-unlock'; import logEntityDeprecation from './utils/log-entity-deprecation'; +import { getSyncProvider } from './sync'; type EntityRecordKey = string | number; @@ -22,7 +23,7 @@ type EntityRecordKey = string | number; * @return The undo manager. */ export function getUndoManager( state: State ) { - return state.undoManager; + return getSyncProvider().getUndoManager() ?? state.undoManager; } /** diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index d9518c3782bd44..c6b79716b90630 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -93,122 +93,125 @@ export const getEntityRecord = ); try { - // Entity supports configs, - // use the sync algorithm instead of the old fetch behavior. + if ( query !== undefined && query._fields ) { + // If requesting specific fields, items and query association to said + // records are stored by ID reference. Thus, fields must always include + // the ID. + query = { + ...query, + _fields: [ + ...new Set( [ + ...( getNormalizedCommaSeparable( query._fields ) || + [] ), + entityConfig.key || DEFAULT_ENTITY_KEY, + ] ), + ].join(), + }; + } + + if ( query !== undefined && query._fields ) { + // The resolution cache won't consider query as reusable based on the + // fields, so it's tested here, prior to initiating the REST request, + // and without causing `getEntityRecord` resolution to occur. + const hasRecord = select.hasEntityRecord( + kind, + name, + key, + query + ); + if ( hasRecord ) { + return; + } + } + + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, + } + ); + const response = await apiFetch( { path, parse: false } ); + const record = await response.json(); + const permissions = getUserPermissionsFromAllowHeader( + response.headers?.get( 'allow' ) + ); + + const canUserResolutionsArgs = []; + const receiveUserPermissionArgs = {}; + for ( const action of ALLOWED_RESOURCE_ACTIONS ) { + receiveUserPermissionArgs[ + getUserPermissionCacheKey( action, { + kind, + name, + id: key, + } ) + ] = permissions[ action ]; + + canUserResolutionsArgs.push( [ + action, + { kind, name, id: key }, + ] ); + } + if ( window.__experimentalEnableSync && - entityConfig.syncConfig && + entityConfig.syncConfig?.enabled && ! query ) { - if ( globalThis.IS_GUTENBERG_PLUGIN ) { - const objectId = entityConfig.getSyncObjectId( key ); - - // Loads the persisted document. - await getSyncProvider().bootstrap( - entityConfig.syncObjectType, - objectId, - ( record ) => { - dispatch.receiveEntityRecords( - kind, - name, - record, - query - ); - } - ); + await getSyncProvider().bootstrap( + // Bootstrap syncing for the entity. + entityConfig.syncConfig, + record, + { + // Handle edits sourced from the sync provider. + editRecord: ( edits ) => { + if ( ! Object.keys( edits ).length ) { + return; + } - // Bootstraps the edited document as well (and load from peers). - await getSyncProvider().bootstrap( - entityConfig.syncObjectType + '--edit', - objectId, - ( record ) => { dispatch( { type: 'EDIT_ENTITY_RECORD', kind, name, recordId: key, - edits: record, + edits, meta: { undo: undefined, }, } ); - } - ); - } - } else { - if ( query !== undefined && query._fields ) { - // If requesting specific fields, items and query association to said - // records are stored by ID reference. Thus, fields must always include - // the ID. - query = { - ...query, - _fields: [ - ...new Set( [ - ...( getNormalizedCommaSeparable( - query._fields - ) || [] ), - entityConfig.key || DEFAULT_ENTITY_KEY, - ] ), - ].join(), - }; - } - - if ( query !== undefined && query._fields ) { - // The resolution cache won't consider query as reusable based on the - // fields, so it's tested here, prior to initiating the REST request, - // and without causing `getEntityRecord` resolution to occur. - const hasRecord = select.hasEntityRecord( - kind, - name, - key, - query - ); - if ( hasRecord ) { - return; - } - } - - const path = addQueryArgs( - entityConfig.baseURL + ( key ? '/' + key : '' ), - { - ...entityConfig.baseURLParams, - ...query, + }, + // Get the current entity record. + getEditedRecord: async () => + await resolveSelect.getEditedEntityRecord( + kind, + name, + key + ), + // Refetch the persisted entity record. + refetchPersistedRecord: () => { + void ( async () => { + dispatch.receiveEntityRecords( + kind, + name, + await apiFetch( { path, parse: true } ), + query + ); + } )(); + }, + // Save the current entity record's unsaved edits. + saveRecord: () => { + dispatch.saveEditedEntityRecord( kind, name, key ); + }, } ); - const response = await apiFetch( { path, parse: false } ); - const record = await response.json(); - const permissions = getUserPermissionsFromAllowHeader( - response.headers?.get( 'allow' ) - ); - - const canUserResolutionsArgs = []; - const receiveUserPermissionArgs = {}; - for ( const action of ALLOWED_RESOURCE_ACTIONS ) { - receiveUserPermissionArgs[ - getUserPermissionCacheKey( action, { - kind, - name, - id: key, - } ) - ] = permissions[ action ]; - - canUserResolutionsArgs.push( [ - action, - { kind, name, id: key }, - ] ); - } - - registry.batch( () => { - dispatch.receiveEntityRecords( kind, name, record, query ); - dispatch.receiveUserPermissions( - receiveUserPermissionArgs - ); - dispatch.finishResolutions( - 'canUser', - canUserResolutionsArgs - ); - } ); } + + registry.batch( () => { + dispatch.receiveEntityRecords( kind, name, record, query ); + dispatch.receiveUserPermissions( receiveUserPermissionArgs ); + dispatch.finishResolutions( 'canUser', canUserResolutionsArgs ); + } ); } finally { dispatch.__unstableReleaseStoreLock( lock ); } diff --git a/packages/core-data/src/selectors.ts b/packages/core-data/src/selectors.ts index 4f5504b50ae0ec..688a43dca821de 100644 --- a/packages/core-data/src/selectors.ts +++ b/packages/core-data/src/selectors.ts @@ -15,6 +15,7 @@ import { getQueriedTotalPages, } from './queried-data'; import { DEFAULT_ENTITY_KEY } from './entities'; +import { getUndoManager } from './private-selectors'; import { getNormalizedCommaSeparable, isRawAttribute, @@ -1139,7 +1140,7 @@ export function getRedoEdit( state: State ): Optional< any > { * @return Whether there is a previous edit or not. */ export function hasUndo( state: State ): boolean { - return state.undoManager.hasUndo(); + return getUndoManager( state ).hasUndo(); } /** @@ -1151,7 +1152,7 @@ export function hasUndo( state: State ): boolean { * @return Whether there is a next edit or not. */ export function hasRedo( state: State ): boolean { - return state.undoManager.hasRedo(); + return getUndoManager( state ).hasRedo(); } /** diff --git a/packages/core-data/src/sync.js b/packages/core-data/src/sync.js deleted file mode 100644 index fdc421a6bd70e9..00000000000000 --- a/packages/core-data/src/sync.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * WordPress dependencies - */ -import { - createSyncProvider, - connectIndexDb, - createWebRTCConnection, -} from '@wordpress/sync'; - -let syncProvider; - -export function getSyncProvider() { - if ( ! syncProvider ) { - syncProvider = createSyncProvider( - connectIndexDb, - createWebRTCConnection( { - signaling: [ - //'ws://localhost:4444', - window?.wp?.ajax?.settings?.url, - ], - password: window?.__experimentalCollaborativeEditingSecret, - } ) - ); - } - - return syncProvider; -} diff --git a/packages/core-data/src/sync.ts b/packages/core-data/src/sync.ts new file mode 100644 index 00000000000000..52c272bdfc67bd --- /dev/null +++ b/packages/core-data/src/sync.ts @@ -0,0 +1,45 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; +import { getWebRTCSyncProvider, SyncProvider } from '@wordpress/sync'; + +declare global { + interface Window { + __experimentalEnableSync?: boolean; + } +} + +let syncProvider: SyncProvider | null = null; + +/** + * Returns the current sync provider, filterable by external code. + * + * If no sync provider is set, it returns a fallback no-op sync provider to + * remove the need for defensive checks in the code that uses it. + * + * @return The current sync provider. + */ +export function getSyncProvider(): SyncProvider { + if ( syncProvider ) { + return syncProvider; + } + + syncProvider = applyFilters( + 'core.getSyncProvider', + null + ) as SyncProvider | null; + + // If the filter does not produce a provider and the experimental flag is set, + // get the WebRTC sync provider. + if ( ! syncProvider && window.__experimentalEnableSync ) { + syncProvider = getWebRTCSyncProvider(); + } + + // If no sync provider is set, use a fallback no-op sync provider. + if ( ! syncProvider ) { + syncProvider = new SyncProvider(); + } + + return syncProvider; +} diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts new file mode 100644 index 00000000000000..5683ab38ebde09 --- /dev/null +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -0,0 +1,425 @@ +/** + * External dependencies + */ +import { v4 as uuidv4 } from 'uuid'; +import * as math from 'lib0/math'; +import * as fun from 'lib0/function'; + +/** + * WordPress dependencies + */ +import { RichTextData } from '@wordpress/rich-text'; +import { Y } 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'; + +interface BlockAttributes { + [ key: string ]: unknown; +} + +interface BlockType { + name: string; + attributes?: Record< string, { type?: string } >; +} + +export interface Block { + attributes: BlockAttributes; + clientId?: string; + innerBlocks: Block[]; + originalContent?: string; // unserializable + validationIssues?: string[]; // unserializable + name: string; +} + +export type YBlock = Y.Map< + /* name, clientId, and originalContent are strings. */ + | string + /* validationIssues? is an array of strings. */ + | string[] + /* attributes is a Y.Map< unknown >. */ + | YBlockAttributes + /* innerBlocks is a Y.Array< YBlock >. */ + | Y.Array< YBlock > +>; + +export type YBlockAttributes = Y.Map< Y.Text | unknown >; + +// The Y.Map type is not easy to work with. The generic type it accepts represents +// the possible values of the map, which are varied in our case. This type is +// accurate, but will require aggressive type narrowing when the map values are +// accessed -- or type casting with `as`. +// export type YBlock = Y.Map< Block[ keyof Block ] >; + +const serializableBlocksCache = new WeakMap< WeakKey, Block[] >(); + +function makeBlockAttributesSerializable( + attributes: BlockAttributes +): BlockAttributes { + const newAttributes = { ...attributes }; + for ( const [ key, value ] of Object.entries( attributes ) ) { + if ( value instanceof RichTextData ) { + newAttributes[ key ] = value.valueOf(); + } + } + return newAttributes; +} + +function makeBlocksSerializable( + blocks: Block[] | Y.Array< YBlock > +): Block[] { + return blocks.map( ( block: Block | YBlock ) => { + const blockAsJson = block instanceof Y.Map ? block.toJSON() : block; + const { name, innerBlocks, attributes, ...rest } = blockAsJson; + delete rest.validationIssues; + delete rest.originalContent; + // delete rest.isValid + return { + ...rest, + name, + attributes: makeBlockAttributesSerializable( attributes ), + innerBlocks: makeBlocksSerializable( innerBlocks ), + }; + } ); +} + +/** + * @param {any} gblock + * @param {Y.Map} yblock + */ +function areBlocksEqual( gblock: Block, yblock: YBlock ): boolean { + const yblockAsJson = yblock.toJSON(); + + // we must not sync clientId, as this can't be generated consistently and + // hence will lead to merge conflicts. + const overwrites = { + innerBlocks: null, + clientId: null, + }; + const res = fun.equalityDeep( + Object.assign( {}, gblock, overwrites ), + Object.assign( {}, yblockAsJson, overwrites ) + ); + const inners = gblock.innerBlocks || []; + const yinners = yblock.get( 'innerBlocks' ) as Y.Array< YBlock >; + return ( + res && + inners.length === yinners.length && + inners.every( ( block: Block, i: number ) => + areBlocksEqual( block, yinners.get( i ) ) + ) + ); +} + +function createNewYAttributeMap( + blockName: string, + attributes: BlockAttributes +): YBlockAttributes { + return new Y.Map( + Object.entries( attributes ).map( + ( [ attributeName, attributeValue ] ) => { + return [ + attributeName, + createNewYAttributeValue( + blockName, + attributeName, + attributeValue + ), + ]; + } + ) + ); +} + +function createNewYAttributeValue( + blockName: string, + attributeName: string, + attributeValue: unknown +): Y.Text | unknown { + const isRichText = isRichTextAttribute( blockName, attributeName ); + + if ( isRichText && 'string' === typeof attributeValue ) { + return new Y.Text( attributeValue ); + } + + return attributeValue; +} + +function createNewYBlock( block: Block ): YBlock { + return new Y.Map( + Object.entries( block ).map( ( [ key, value ] ) => { + switch ( key ) { + case 'attributes': { + return [ key, createNewYAttributeMap( block.name, value ) ]; + } + + case 'innerBlocks': { + const innerBlocks = new Y.Array(); + + // If not an array, set to empty Y.Array. + if ( ! Array.isArray( value ) ) { + return [ key, innerBlocks ]; + } + + innerBlocks.insert( + 0, + value.map( ( innerBlock: Block ) => + createNewYBlock( innerBlock ) + ) + ); + + return [ key, innerBlocks ]; + } + + default: + return [ key, value ]; + } + } ) + ); +} + +/** + * Merge incoming block data into the local Y.Doc. + * 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. + * @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 + _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars +): void { + // Ensure we are working with serializable block data. + if ( ! serializableBlocksCache.has( incomingBlocks ) ) { + serializableBlocksCache.set( + incomingBlocks, + makeBlocksSerializable( incomingBlocks ) + ); + } + const allBlocks = serializableBlocksCache.get( incomingBlocks ) ?? []; + + // Ensure we skip blocks that we don't want to sync at the moment + const blocksToSync = allBlocks.filter( ( block ) => + shouldBlockBeSynced( block ) + ); + + // This is a rudimentary diff implementation similar to the y-prosemirror diffing + // approach. + // A better implementation would also diff the textual content and represent it + // using a Y.Text type. + // However, at this time it makes more sense to keep this algorithm generic to + // support all kinds of block types. + // Ideally, we ensure that block data structure have a consistent data format. + // E.g.: + // - textual content (using rich-text formatting?) may always be stored under `block.text` + // - local information that shouldn't be shared (e.g. clientId or isDragging) is stored under `block.private` + const numOfCommonEntries = math.min( + blocksToSync.length ?? 0, + yblocks.length + ); + + let left = 0; + let right = 0; + + // skip equal blocks from left + for ( + ; + left < numOfCommonEntries && + areBlocksEqual( blocksToSync[ left ], yblocks.get( left ) ); + left++ + ) { + /* nop */ + } + + // skip equal blocks from right + for ( + ; + right < numOfCommonEntries - left && + areBlocksEqual( + blocksToSync[ blocksToSync.length - right - 1 ], + yblocks.get( yblocks.length - right - 1 ) + ); + right++ + ) { + /* nop */ + } + + const numOfUpdatesNeeded = numOfCommonEntries - left - right; + const numOfInsertionsNeeded = math.max( + 0, + blocksToSync.length - yblocks.length + ); + const numOfDeletionsNeeded = math.max( + 0, + yblocks.length - blocksToSync.length + ); + + // updates + for ( let i = 0; i < numOfUpdatesNeeded; i++, left++ ) { + const block = blocksToSync[ left ]; + const yblock = yblocks.get( left ); + Object.entries( block ).forEach( ( [ key, value ] ) => { + switch ( key ) { + case 'attributes': { + const currentAttributes = yblock.get( + key + ) as YBlockAttributes; + + // If attributes are not set on the yblock, use the new values. + if ( ! currentAttributes ) { + yblock.set( + key, + createNewYAttributeMap( block.name, value ) + ); + break; + } + + Object.entries( value ).forEach( + ( [ attributeName, attributeValue ] ) => { + if ( + fun.equalityDeep( + currentAttributes?.get( attributeName ), + attributeValue + ) + ) { + return; + } + + currentAttributes.set( + attributeName, + createNewYAttributeValue( + block.name, + attributeName, + attributeValue + ) + ); + } + ); + + // Delete any attributes that are no longer present. + currentAttributes.forEach( + ( _attrValue: unknown, attrName: string ) => { + if ( ! value.hasOwnProperty( attrName ) ) { + currentAttributes.delete( attrName ); + } + } + ); + + break; + } + + case 'innerBlocks': { + // Recursively merge innerBlocks + const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; + mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin ); + break; + } + + default: + if ( + ! fun.equalityDeep( block[ key ], yblock.get( key ) ) + ) { + yblock.set( key, value ); + } + } + } ); + yblock.forEach( ( _v, k ) => { + if ( ! block.hasOwnProperty( k ) ) { + yblock.delete( k ); + } + } ); + } + + // deletes + yblocks.delete( left, numOfDeletionsNeeded ); + + // inserts + for ( let i = 0; i < numOfInsertionsNeeded; i++, left++ ) { + const newBlock = [ createNewYBlock( blocksToSync[ left ] ) ]; + + yblocks.insert( left, newBlock ); + } + + // remove duplicate clientids + const knownClientIds = new Set< string >(); + for ( let j = 0; j < yblocks.length; j++ ) { + const yblock: YBlock = yblocks.get( j ); + + let clientId: string = yblock.get( 'clientId' ) as string; + + if ( knownClientIds.has( clientId ) ) { + clientId = uuidv4(); + yblock.set( 'clientId', clientId ); + } + knownClientIds.add( clientId ); + } +} + +/** + * Determine if a block should be synced. + * + * Ex: A gallery block should not be synced until the images have been + * uploaded to WordPress, and their url is available. Before that, + * it's not possible to access the blobs on a client as those are + * local. + * + * @param block The block to check. + * @return True if the block should be synced, false otherwise. + */ +function shouldBlockBeSynced( block: Block ): boolean { + // Verify that the gallery block is ready to be synced. + // This means that, all images have had their blobs converted to full URLs. + // Checking for only the blobs ensures that blocks that have just been inserted work as well. + if ( 'core/gallery' === block.name ) { + return ! block.innerBlocks.some( + ( innerBlock ) => + innerBlock.attributes && innerBlock.attributes.blob + ); + } + + // Allow all other blocks to be synced. + return true; +} + +// Cache rich-text attributes for all block types. +let cachedRichTextAttributes: Map< string, Map< string, true > >; + +/** + * Given a block name and attribute key, return true if the attribute is rich-text typed. + * + * @param blockName The name of the block, e.g. 'core/paragraph'. + * @param attributeName The name of the attribute to check, e.g. 'content'. + * @return True if the attribute is rich-text typed, false otherwise. + */ +function isRichTextAttribute( + blockName: string, + attributeName: string +): boolean { + if ( ! cachedRichTextAttributes ) { + // Parse the attributes for all blocks once. + cachedRichTextAttributes = new Map< string, Map< string, true > >(); + + for ( const blockType of getBlockTypes() as BlockType[] ) { + const richTextAttributeMap = new Map< string, true >(); + + for ( const [ name, definition ] of Object.entries( + blockType.attributes ?? {} + ) ) { + if ( 'rich-text' === definition.type ) { + richTextAttributeMap.set( name, true ); + } + } + + cachedRichTextAttributes.set( + blockType.name, + richTextAttributeMap + ); + } + } + + return ( + cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false + ); +} diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts new file mode 100644 index 00000000000000..b4bb948abd4595 --- /dev/null +++ b/packages/core-data/src/utils/crdt.ts @@ -0,0 +1,550 @@ +/** + * External dependencies + */ +import * as fun from 'lib0/function'; + +/** + * WordPress dependencies + */ +// @ts-ignore No types available. +import { parse } from '@wordpress/blocks'; +import { applyFilters } from '@wordpress/hooks'; +import { + type CRDTDoc, + CRDT_RECORD_MAP_KEY, + type ObjectData, + Y, +} from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; +import { type Post } from '../entity-types/post'; +import { type Type } from '../entity-types'; + +type PostChanges = Partial< Post > & { blocks?: Block[] }; + +/** + * 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} + */ +export function defaultApplyChangesToCRDTDoc( + ydoc: CRDTDoc, + changes: ObjectData +): void { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + + Object.entries( changes ).forEach( ( [ key, newValue ] ) => { + // Cannot serialize function values, so cannot sync them. + if ( 'function' === typeof newValue ) { + return; + } + + // Set the value in the root document. + function setValue< T = unknown >( updatedValue: T ): void { + ymap.set( key, updatedValue ); + } + + switch ( key ) { + // Add support for additional data types here. + + default: { + const currentValue = ymap.get( key ); + mergeValue( currentValue, newValue, setValue ); + } + } + } ); +} + +/** + * 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 {Post} rawRecord + * @param {Type} postType + * @param {Set< string >} syncedProperties + * @param {string} origin + * @return {void} + */ +export function applyPostChangesToCRDTDoc( + ydoc: CRDTDoc, + changes: PostChanges, + rawRecord: Post, + postType: Type, + syncedProperties: Set< string >, + origin: string +): void { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + + Object.entries( changes ).forEach( ( [ key, newValue ] ) => { + if ( ! syncedProperties.has( key ) ) { + return; + } + + // Cannot serialize function values, so cannot sync them. + if ( 'function' === typeof newValue ) { + return; + } + + // Set the value in the root document. + function setValue< T = unknown >( updatedValue: T ): void { + ymap.set( key, updatedValue ); + } + + switch ( key ) { + case 'blocks': { + let currentBlocks = ymap.get( 'blocks' ) as Y.Array< YBlock >; + + // Initialize. + if ( ! ( currentBlocks instanceof Y.Array ) ) { + currentBlocks = new Y.Array(); + setValue( currentBlocks ); + } + + // Block[] from local changes. + const newBlocks = ( newValue as PostChanges[ 'blocks' ] ) ?? []; + + // Merge blocks does not need `setValue` because it is operating on a + // Yjs type that is already in the Y.Doc. + mergeCrdtBlocks( currentBlocks, newBlocks, origin ); + break; + } + + case 'excerpt': { + const currentValue = ymap.get( 'excerpt' ) as + | string + | undefined; + const rawNewValue = getRawValue( newValue ); + + mergeValue( currentValue, rawNewValue, setValue ); + break; + } + + // Meta is overloaded term in Core; here, it refers to post meta. + case 'meta': { + let metaMap = ymap.get( 'meta' ) as Y.Map< unknown >; + + // Initialize. + if ( ! ( metaMap instanceof Y.Map ) ) { + metaMap = new Y.Map(); + setValue( metaMap ); + } + + // Iterate over each meta property in the new value and merge it (if it + // is a synced meta property). + Object.entries( newValue ?? {} ).forEach( + ( [ metaKey, metaValue ] ) => { + if ( + ! shouldSyncMetaForPostType( metaKey, postType ) + ) { + return; + } + + mergeValue( + metaMap.get( metaKey ), // current value in CRDT + metaValue, // new value from local changes + ( updatedMetaValue: unknown ): void => { + metaMap.set( metaKey, updatedMetaValue ); + } + ); + } + ); + 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( 'slug' ) as string; + mergeValue( currentValue, newValue, setValue ); + break; + } + + case 'status': { + const currentValue = ymap.get( 'status' ) as string | undefined; + let newStatus = newValue; + + // Undefined status indicates that we want to reset to the current + // persisted value. + if ( undefined === newStatus ) { + newStatus = rawRecord.status; + } + + mergeValue( currentValue, newStatus, setValue ); + break; + } + + case 'title': { + const currentValue = ymap.get( 'title' ) as string | undefined; + + // Copy logic from prePersistPostType to ensure that the "Auto + // Draft" template title is not synced. + let rawNewValue = getRawValue( newValue ); + if ( ! currentValue && 'Auto Draft' === rawNewValue ) { + rawNewValue = ''; + } + + mergeValue( currentValue, rawNewValue, setValue ); + break; + } + + // Add support for additional data types here. + + default: { + const currentValue = ymap.get( key ); + mergeValue( currentValue, newValue, setValue ); + } + } + } ); +} + +/** + * 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 {ObjectData} record + * @return {Partial} The changes that should be applied to the local record. + */ +export function defaultGetChangesFromCRDTDoc( + ydoc: CRDTDoc, + record: ObjectData +): Partial< ObjectData > { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + + return Object.fromEntries( + Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => { + const currentValue = record[ key ]; + + switch ( key ) { + // Add support for additional data types here. + + default: { + return haveValuesChanged( currentValue, 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} record + * @param {Type} postType + * @param {Set< string >} syncedProperties + * @return {Partial} The changes that should be applied to the local record. + */ +export function getPostChangesFromCRDTDoc( + ydoc: CRDTDoc, + record: Post, + postType: Type, + syncedProperties: Set< string > +): PostChanges { + const ymap = ydoc.getMap( CRDT_RECORD_MAP_KEY ); + + return Object.fromEntries( + Object.entries( ymap.toJSON() ).filter( ( [ key, newValue ] ) => { + if ( ! syncedProperties.has( key ) ) { + return false; + } + + const currentValue = record[ key ]; + + switch ( key ) { + case 'blocks': { + // We don't need to add special equality checks for `blocks` here + // since that is done by the store for us! + return true; + } + + case 'date': { + // Do not sync an empty date if our current value is a "floating" date. + // Borrowing logic from the isEditedPostDateFloating selector. + const currentDateIsFloating = + [ 'draft', 'auto-draft', 'pending' ].includes( + ymap.get( 'status' ) as string + ) && + ( null === currentValue || + record.modified === currentValue ); + + if ( ! newValue && currentDateIsFloating ) { + return false; + } + + return haveValuesChanged( currentValue, newValue ); + } + + case 'meta': { + const allowedMeta = Object.fromEntries( + Object.entries( newValue ?? {} ).filter( + ( [ metaKey ] ) => + shouldSyncMetaForPostType( metaKey, postType ) + ) + ); + + // Merge the allowed meta changes with the current meta values since + // not all meta properties are synced. + const mergedValue = { + ...( currentValue as PostChanges[ 'meta' ] ), + ...allowedMeta, + }; + + return haveValuesChanged( currentValue, mergedValue ); + } + + case 'status': { + // Do not sync an invalid status. + if ( 'auto-draft' === newValue ) { + return false; + } + + return haveValuesChanged( currentValue, newValue ); + } + + case 'excerpt': + case 'title': { + return haveValuesChanged( + getRawValue( currentValue ), + newValue + ); + } + + // Add support for additional data types here. + + default: { + return haveValuesChanged( currentValue, newValue ); + } + } + } ) + ); +} + +/** + * Given a raw entity record, return the initial data that should be loaded + * into the CRDT document. + * + * @param {ObjectData} record The raw entity record. + * @return {ObjectData} The initial data to load into the CRDT document. + */ +export function defaultGetInitialObjectData( record: ObjectData ): ObjectData { + return record; +} + +/** + * Given a raw post entity record, return the initial data that should be loaded + * into the CRDT document. + * + * @param {Post} record The raw entity record. + * @param {Type} postType The post type definition. + * @param {Set} syncedProperties The set of properties that should be synced for this post type. + * @return {ObjectData} The initial data to load into the CRDT document. + */ +export function getInitialPostObjectData( + record: Post, + postType: Type, + syncedProperties: Set< string > +): PostChanges { + // Mix in the parsed blocks. + const blocks = parse( getRawValue( record.content ) ); + + return Object.fromEntries( + Object.entries( { ...record, blocks } ) + // Only allow properties in the synced properties set. + .filter( ( [ key ] ) => syncedProperties.has( key ) ) + .map( ( [ key, value ] ) => { + switch ( key ) { + case 'content': + case 'excerpt': + case 'title': { + return [ key, getRawValue( value ) ]; + } + + case 'meta': { + return [ + key, + Object.fromEntries( + Object.entries( value ?? {} ).filter( + ( [ metaKey ] ) => + shouldSyncMetaForPostType( + metaKey, + postType + ) + ) + ), + ]; + } + } + + return [ key, value ]; + } ) + ); +} + +/** + * 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 = any >( + currentValue: ValueType, + newValue: ValueType +): boolean { + return ! fun.equalityDeep( currentValue, newValue ); +} + +function mergeValue< ValueType = any >( + currentValue: ValueType, + newValue: ValueType, + setValue: ( value: ValueType ) => void +): void { + if ( haveValuesChanged< ValueType >( currentValue, newValue ) ) { + setValue( newValue ); + } +} + +/** + * Given a post type definition, return the set of properties that should be + * synced for that post type. + * + * @param {Type} postType The post type definition. + * @return {Set} The set of properties that should be synced. + */ +export function getSyncedPropertiesForPostType( + postType: Type +): Set< string > { + const syncedProperties = new Set< string >( [ + 'date', + 'status', + 'tags', + 'template', + 'slug', + 'sticky', + ] ); + + Object.entries( postType.supports || {} ).forEach( + ( [ feature, isSupported ] ) => { + if ( ! isSupported ) { + return; + } + + switch ( feature ) { + case 'author': + syncedProperties.add( 'author' ); + break; + case 'comments': + syncedProperties.add( 'comment_status' ); + break; + case 'custom-fields': + syncedProperties.add( 'meta' ); + break; + case 'editor': + syncedProperties.add( 'blocks' ); + break; + case 'excerpt': + syncedProperties.add( 'excerpt' ); + break; + case 'post-formats': + syncedProperties.add( 'format' ); + break; + case 'thumbnail': + syncedProperties.add( 'featured_media' ); + break; + case 'trackbacks': + syncedProperties.add( 'ping_status' ); + break; + case 'title': + syncedProperties.add( 'title' ); + break; + } + } + ); + + return syncedProperties; +} + +const metaDecisionCache: Map< string, Map< string, boolean > > = new Map(); + +/** + * Given a meta key and post type definition, return a decision on whether to + * sync the meta property. + * + * @param {string} metaKey The meta key. + * @param {Type} postType The post type definition. + * @return {boolean} Whether to sync the meta property. + */ +function shouldSyncMetaForPostType( metaKey: string, postType: Type ): boolean { + if ( ! metaDecisionCache.has( postType.slug ) ) { + metaDecisionCache.set( postType.slug, new Map() ); + } + + const decisionMap = metaDecisionCache.get( postType.slug )!; + + if ( decisionMap.has( metaKey ) ) { + return decisionMap.get( metaKey )!; + } + + /** + * In order to be available to the sync module, meta properties must be + * registered against the post type and made available via the REST API + * (`'show_in_rest' => true`). + * + * Of the registered meta properties, by default we do not sync "hidden" meta + * fields (leading underscore in the meta key). This filter allows third-party + * code to override that behavior. + * + * @param {boolean} shouldSync Whether to sync the meta property. + * @param {string} metaKey Meta key. + * @param {string} postTypeSlug The post type slug. + * @param {Type} postType The post type definition. + * @return {boolean} The filtered list of meta properties to sync. + */ + const shouldSync = Boolean( + applyFilters( + 'sync.shouldSyncMeta', + ! metaKey.startsWith( '_' ), + metaKey, + postType.slug, + postType + ) + ); + + decisionMap.set( metaKey, shouldSync ); + + return shouldSync; +} diff --git a/packages/core-data/tsconfig.json b/packages/core-data/tsconfig.json index 57c9d208e4c689..da362841c47955 100644 --- a/packages/core-data/tsconfig.json +++ b/packages/core-data/tsconfig.json @@ -3,7 +3,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "checkJs": false, - "noImplicitAny": false + "noImplicitAny": false, + "types": [ "node" ] }, "references": [ { "path": "../api-fetch" }, @@ -13,6 +14,7 @@ { "path": "../deprecated" }, { "path": "../element" }, { "path": "../html-entities" }, + { "path": "../hooks" }, { "path": "../i18n" }, { "path": "../is-shallow-equal" }, { "path": "../private-apis" }, diff --git a/packages/dependency-extraction-webpack-plugin/lib/util.js b/packages/dependency-extraction-webpack-plugin/lib/util.js index b5c9f9057c2052..11d24cb433026a 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/util.js +++ b/packages/dependency-extraction-webpack-plugin/lib/util.js @@ -8,7 +8,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields', diff --git a/packages/docgen/lib/get-export-entries.js b/packages/docgen/lib/get-export-entries.js index dc4046b72fcc77..9785f40808b94d 100644 --- a/packages/docgen/lib/get-export-entries.js +++ b/packages/docgen/lib/get-export-entries.js @@ -54,10 +54,10 @@ module.exports = ( token ) => { } const name = []; - if ( token.declaration === null ) { + if ( ! token.declaration ) { token.specifiers.forEach( ( specifier ) => name.push( { - localName: specifier.local.name, + localName: specifier.local?.name, exportName: specifier.exported.name, module: token.source?.value ?? null, lineStart: specifier.loc.start.line, diff --git a/packages/editor/src/components/local-autosave-monitor/index.js b/packages/editor/src/components/local-autosave-monitor/index.js index ad4e40d15d5c58..4733612e58a31c 100644 --- a/packages/editor/src/components/local-autosave-monitor/index.js +++ b/packages/editor/src/components/local-autosave-monitor/index.js @@ -101,6 +101,19 @@ function useAutosaveNotice() { return; } + // Disable the warning notice if collaborative editing is enabled. + // + // @TODO + // + // In the future, we may wish to implement a more sophisticated check -- for + // example, if collaborative editing is enabled but the provider is + // disconnected, we may want to provide the user with options. For now, + // however, since we effectively lock the editor when the provider is not + // connected, this simplistic approach will work. + if ( window.__experimentalEnableSync ) { + return; + } + const id = 'wpEditorAutosaveRestore'; createWarningNotice( diff --git a/packages/editor/src/components/post-locked-modal/index.js b/packages/editor/src/components/post-locked-modal/index.js index 65225a96508ac1..6ae4d39a1a430c 100644 --- a/packages/editor/src/components/post-locked-modal/index.js +++ b/packages/editor/src/components/post-locked-modal/index.js @@ -148,6 +148,11 @@ function PostLockedModal() { return null; } + // Potentially refactor this into the above shortcircuit (!isLocked). + if ( window.__experimentalEnableSync ) { + return null; + } + const userDisplayName = user.name; const userAvatar = user.avatar; diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 55db84b93fcde1..97824fabd56182 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -296,7 +296,17 @@ export const ExperimentalEditorProvider = withRegistryProvider( updatePostLock( settings.postLock ); setupEditor( post, initialEdits, settings.template ); - if ( settings.autosave ) { + + // Disable the warning notice if collaborative editing is enabled. + // + // @TODO + // + // In the future, we may wish to implement a more sophisticated check -- for + // example, if collaborative editing is enabled but the provider is + // disconnected, we may want to provide the user with options. For now, + // however, since we effectively lock the editor when the provider is not + // connected, this simplistic approach will work. + if ( settings.autosave && ! window.__experimentalEnableSync ) { createWarningNotice( __( 'There is an autosave of this post that is more recent than the version below.' diff --git a/packages/sync/CODE.md b/packages/sync/CODE.md index 40a4b76d2cfd42..e129a41f3a7cd2 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -1,54 +1,60 @@ # Status of the sync experiment in Gutenberg -The sync package is part of an ongoing research effort to lay the groundwork of Real-Time Collaboration in Gutenberg. +The sync package provides an implementation of real-time collaboration in Gutenberg. -Relevant docs: +Relevant docs and discussions: -- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/ -- https://github.com/WordPress/gutenberg/issues/52593 -- https://docs.yjs.dev/ +- https://make.wordpress.org/core/2023/07/13/real-time-collaboration-architecture/ +- https://github.com/WordPress/gutenberg/issues/52593 +- https://github.com/WordPress/gutenberg/discussions/65012 +- https://docs.yjs.dev/ ## Enable the experiment -The experiment can be enabled in the "Guteberg > Experiments" page. When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data: +The real-time collaboration experiment must be enabled on the "Gutenberg > Experiments" page. By default, a WebRTC provider with HTTP signaling is used to connect peers. Alternatively, you can load a custom sync provider via a filter: -- `window.__experimentalEnableSync`: boolean. Used by the `core-data` package to determine whether to bootstrap and use the sync provider offered by the `sync` package. -- `window.__experimentalCollaborativeEditingSecret`: string. A secret used by the `sync` package to create a secure connection among peers. +```js +addFilter( 'core.getSyncProvider', 'my-plugin/custom-sync-provider', () => { + return new SyncProvider( /* ...args */ ); +} ); +``` + +When it is enabled, the following global variables are defined:: + +- `window.__experimentalEnableSync` (`boolean`): Used by the `core-data` package to determine whether entity syncing is available. +- `window.__experimentalCollaborativeEditingSecret` (`string`). A secret (stored in a WordPress option) used by the WebRTC provider to create a secure connection between peers. ## The data flow -The current experiment updates `core-data` to leverage the YJS library for synchronization and merging changes. Each core-data entity record represents a YJS document and updates to the `--edit` record are broadcasted among peers. +Each entity with sync enabled is represented by a CRDT (Yjs) document. Local edits (unsaved changes) to an entity record are applied to its CRDT document, which is synced with other peers via a provider. Those peers use the CRDT document to update their local state. These are the specific checkpoints: -1. REGISTER. - - See `getSyncProvider().register( ... )` in `registerSyncConfigs`. - - Not all entity types are sync-enabled at the moment, look at those that declare a `syncConfig` and `syncObjectType` in `rootEntitiesConfig`. -2. BOOTSTRAP. - - See `getSyncProvider().bootstrap( ... )` in `getEntityRecord`. - - The `bootstrap` function fetches the entity and sets up the callback that will dispatch the relevant Redux action when document changes are broadcasted from other peers. -3. UPDATE. - - See `getSyncProvider().update( ... )` in `editEntityRecord`. - - Each change done by a peer to the `--edit` entity record (local changes, not persisted ones) is broadcasted to the others. - - The data that is shared is the whole block list. - -This is the data flow when the peer A makes a local change: - -- Peer A makes a local change. -- Peer A triggers a `getSyncProvider().update( ... )` request (see `editEntityRecord`). -- All peers (including A) receive the broadcasted change and execute the callback (see `updateHandler` in `createSyncProvider.bootstrap`). -- All peers (including A) trigger a `EDIT_ENTITY_RECORD` redux action. - -## What works and what doesn't - -- Undo/redo does not work. -- Changes can be persisted and the publish/update button should react accordingly for all peers. -- Offline. - - Changes are stored in the browser's local storage (indexedDB) for each user/peer. Users can navigate away from the document and they'll see the changes when they come back. - - Offline changes can be deleted via visiting the browser's database in all peers, then reload the document. -- Documents can get out of sync. For example: - - Two peers open the same document. - - One of them (A) leaves the document. Then, the remaining user (B) makes changes. - - When A comes back to the document, the changes B made are not visible to A. -- Entities - - Not all entities are synced. For example, global styles are not. Look at the `base` entity config for an example (it declares `syncConfig` and `syncObjectType` properties). +1. **CONFIG**: The entity's config defines a `syncConfig` property to enable syncing for that entity type and define its behavior. + - See `packages/core-data/src/entities.js`. + - Not all entities are sync-enabled; look for those that define a `syncConfig` property. + - Not all properties are synced; look for the `syncProperties` set that is passed as an argument to various functions. +2. **BOOTSTRAP**: When an entity record is loaded for the first time and it supports syncing, it is "bootstrapped" to provide handlers for various lifecycle events. + - See `getEntityRecord` in `packages/core-data/src/resolvers.js`. + - See `SyncProvider#bootstrap()` in this package. +3. **LOCAL CHANGES**: When local changes are made to an entity record, it is applied to the entity's CRDT document, which is synced with peers. + - See `editEntityRecord` in `packages/core-data/src/actions.js`. + - See `SyncProvider#updateCRDTDoc()` in this package. +4. **REMOTE CHANGES**: When an entity's CRDT document is updated by a remote peer, changes are extracted and the entity record is updated in the local store. + - See `SyncProvider#updateEntityRecord` in this package. +5. **PERSISTED CHANGES**: When an entity record is persisted (saved) to the database, other peers receive a signal that they should refresh their local copy of the entity record. + - See `saveEntityRecord` in `packages/core-data/src/actions.js`. + - See `SyncProvider#updateLastPersistedDate` in this package. + +While the Redux actions in `core-data` and the `SyncProvider` orchestrate this data flow, the behavior of what gets synced is controlled by the entity's `syncConfig`: + +- `enabled` determines whether syncing is enabled for the entity type. This could vary based on context (e.g., post type). +- `applyChangesToCRDTDoc` determines how (or if) local changes are applied to the CRDT document. +- `getChangesFromCRDTDoc` determines how (or if) changes from the CRDT document are extracted and applied to the entity record. +- `getInitialObjectData` determines the initial state of the CRDT document when it is first created and can be used to create computed or meta properties for syncing (e.g., `blocks` are computed from `content`). +- `getObjectId` extracts an entity's immutable ID from an entity record. +- `objectType` is a unique string that identifies the entity type. +- `supports` is a hash that declares support for various sync features, present and future. +- `syncedProperties` is the set of entity properties that should be synced (possibly including computed or meta properties from `getInitialObjectData`). + +An entity's `syncConfig` "owns" the sync behavior of the entity (especially via `applyChangesToCRDTDoc` and `getChangesFromCRDTDoc`) and it should not delegate or leak that responsibility to other parts of the codebase. diff --git a/packages/sync/README.md b/packages/sync/README.md index f15d61b5a1eb5f..aa9298e97da03f 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -14,46 +14,29 @@ npm install @wordpress/sync --save -### connectIndexDb +### CRDT_RECORD_MAP_KEY -Connect function to the IndexedDB persistence provider. +Root-level key for the CRDT document that holds the entity record data. -_Parameters_ +### getWebRTCSyncProvider -- _objectId_ `ObjectID`: The object ID. -- _objectType_ `ObjectType`: The object type. -- _doc_ `CRDTDoc`: The CRDT document. +Returns a WebRTC sync provider. This is the curent default sync provider. _Returns_ -- `Promise<() => void>`: Promise that resolves when the connection is established. +- `SyncProvider`: The WebRTC sync provider. -### createSyncProvider +### SyncProvider -Create a sync provider. +SyncProvider manages the lifecycle of syncing entity records. It establishes connections, creates the awareness instance, and coordinates with the local store. -_Parameters_ +_Type_ -- _connectLocal_ `ConnectDoc`: Connect the document to a local database. -- _connectRemote_ `ConnectDoc`: Connect the document to a remote sync connection. +- `SyncProvider` -_Returns_ - -- `SyncProvider`: Sync provider. - -### createWebRTCConnection - -Function that creates a new WebRTC Connection. - -_Parameters_ - -- _config_ `Object`: The object ID. -- _config.signaling_ `Array`: -- _config.password_ `string`: - -_Returns_ +### Y -- `Function`: Promise that resolves when the connection is established. +Exported copy of Yjs so that consumers of this package don't need to install it. diff --git a/packages/sync/package.json b/packages/sync/package.json index 68b7b3394b4a55..99060dc6b11fda 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -29,16 +29,13 @@ "types": "build-types", "sideEffects": false, "dependencies": { - "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", "@wordpress/url": "file:../url", - "import-locals": "^2.0.0", - "lib0": "^0.2.42", + "lib0": "^0.2.99", "simple-peer": "^9.11.0", - "y-indexeddb": "~9.0.11", - "y-protocols": "^1.0.5", - "y-webrtc": "~10.2.5", - "yjs": "~13.6.6" + "y-indexeddb": "^9.0.12", + "y-protocols": "^1.0.6", + "yjs": "13.6.27" }, "publishConfig": { "access": "public" diff --git a/packages/sync/src/config.ts b/packages/sync/src/config.ts new file mode 100644 index 00000000000000..356f565677673a --- /dev/null +++ b/packages/sync/src/config.ts @@ -0,0 +1,28 @@ +/** + * This version number should be incremented whenever there are breaking changes + * to Yjs doc schema or in how it is interpreted by code in the SyncConfig. This + * allows implementors to invalidate persisted CRDT docs. + */ +export const CRDT_DOC_VERSION = 1; + +/** + * Root-level key for the CRDT document that holds the entity record data. + */ +export const CRDT_RECORD_MAP_KEY = 'document'; + +/** + * Root-level key for the CRDT document that holds the state descriptors (see + * below). + */ +export const CRDT_STATE_MAP_KEY = 'state'; + +// Y.Map keys for the state map. +export const CRDT_STATE_PERSISTED_AT_KEY = 'persistedAt'; +export const CRDT_STATE_PERSISTED_BY_KEY = 'persistedBy'; +export const CRDT_STATE_RESTORED_AT_KEY = 'restoredAt'; +export const CRDT_STATE_RESTORED_BY_KEY = 'restoredBy'; +export const CRDT_STATE_VERSION_KEY = 'version'; + +// Local origin strings. +export const LOCAL_EDITOR_ORIGIN = 'gutenberg'; +export const LOCAL_SYNC_PROVIDER_ORIGIN = 'syncProvider'; diff --git a/packages/sync/src/connect-indexdb.js b/packages/sync/src/connect-indexdb.js index 5523640408f575..5329f066b296ea 100644 --- a/packages/sync/src/connect-indexdb.js +++ b/packages/sync/src/connect-indexdb.js @@ -8,7 +8,7 @@ import { IndexeddbPersistence } from 'y-indexeddb'; /** @typedef {import('./types').ObjectID} ObjectID */ /** @typedef {import('./types').CRDTDoc} CRDTDoc */ /** @typedef {import('./types').ConnectDoc} ConnectDoc */ -/** @typedef {import('./types').SyncProvider} SyncProvider */ +/** @typedef {import('./types').ConnectDocResult} ConnectDocResult */ /** * Connect function to the IndexedDB persistence provider. @@ -17,15 +17,13 @@ import { IndexeddbPersistence } from 'y-indexeddb'; * @param {ObjectType} objectType The object type. * @param {CRDTDoc} doc The CRDT document. * - * @return {Promise<() => void>} Promise that resolves when the connection is established. + * @return {Promise< ConnectDocResult >} Promise that resolves when the connection is established. */ export function connectIndexDb( objectId, objectType, doc ) { const roomName = `${ objectType }-${ objectId }`; const provider = new IndexeddbPersistence( roomName, doc ); - return new Promise( ( resolve ) => { - provider.on( 'synced', () => { - resolve( () => provider.destroy() ); - } ); + return Promise.resolve( { + destroy: () => provider.destroy(), } ); } diff --git a/packages/sync/src/create-webrtc-connection.js b/packages/sync/src/create-webrtc-connection.js index 97fcddc727d024..ee03799e9a3a40 100644 --- a/packages/sync/src/create-webrtc-connection.js +++ b/packages/sync/src/create-webrtc-connection.js @@ -10,30 +10,31 @@ import { WebrtcProviderWithHttpSignaling } from './webrtc-http-stream-signaling' /** @typedef {import('./types').ObjectType} ObjectType */ /** @typedef {import('./types').ObjectID} ObjectID */ +/** @typedef {import('./types').ConnectDoc} ConnectDoc */ /** @typedef {import('./types').CRDTDoc} CRDTDoc */ /** * Function that creates a new WebRTC Connection. * - * @param {Object} config The object ID. - * - * @param {Array} config.signaling - * @param {string} config.password - * @return {Function} Promise that resolves when the connection is established. + * @param {Object} config + * @param {Array} config.signaling + * @param {string|undefined} config.password + * @return {ConnectDoc} Promise that resolves when the connection is established. */ export function createWebRTCConnection( { signaling, password } ) { return function ( - /** @type {string} */ objectId, - /** @type {string} */ objectType, - /** @type {import("yjs").Doc} */ doc + /** @type {ObjectID} */ objectId, + /** @type {ObjectType} */ objectType, + /** @type {CRDTDoc} */ doc ) { const roomName = `${ objectType }-${ objectId }`; - new WebrtcProviderWithHttpSignaling( roomName, doc, { + const provider = new WebrtcProviderWithHttpSignaling( roomName, doc, { signaling, - // @ts-ignore password, } ); - return Promise.resolve( () => true ); + return Promise.resolve( { + destroy: () => provider.destroy(), + } ); }; } diff --git a/packages/sync/src/index.js b/packages/sync/src/index.js deleted file mode 100644 index 6c2b6899ffb618..00000000000000 --- a/packages/sync/src/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export { connectIndexDb } from './connect-indexdb'; -export { createWebRTCConnection } from './create-webrtc-connection'; -export { createSyncProvider } from './provider'; diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts new file mode 100644 index 00000000000000..41a98374ae159a --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,50 @@ +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import { connectIndexDb } from './connect-indexdb'; +import { createWebRTCConnection } from './create-webrtc-connection'; +import { SyncProvider } from './provider'; + +/** + * Exported copy of Yjs so that consumers of this package don't need to install it. + */ +export * as Y from 'yjs'; + +export { CRDT_RECORD_MAP_KEY } from './config'; +export { SyncProvider } from './provider'; +export * from './types'; + +declare global { + interface Window { + __experimentalCollaborativeEditingSecret?: string; + wp: { + ajax: { + settings: { + url: string; + }; + }; + }; + } +} + +/** + * Returns a WebRTC sync provider. This is the curent default sync provider. + * + * @return {SyncProvider} The WebRTC sync provider. + */ +export function getWebRTCSyncProvider(): SyncProvider { + return new SyncProvider( [ + connectIndexDb, + createWebRTCConnection( { + password: window?.__experimentalCollaborativeEditingSecret, + signaling: [ + //'ws://localhost:4444', + window?.wp?.ajax?.settings?.url, + ], + } ), + ] ); +} diff --git a/packages/sync/src/provider.js b/packages/sync/src/provider.js deleted file mode 100644 index 0be1dedab5d308..00000000000000 --- a/packages/sync/src/provider.js +++ /dev/null @@ -1,128 +0,0 @@ -/** - * External dependencies - */ -// @ts-ignore -import * as Y from 'yjs'; - -/** @typedef {import('./types').ObjectType} ObjectType */ -/** @typedef {import('./types').ObjectID} ObjectID */ -/** @typedef {import('./types').ObjectConfig} ObjectConfig */ -/** @typedef {import('./types').CRDTDoc} CRDTDoc */ -/** @typedef {import('./types').ConnectDoc} ConnectDoc */ -/** @typedef {import('./types').SyncProvider} SyncProvider */ - -/** - * Create a sync provider. - * - * @param {ConnectDoc} connectLocal Connect the document to a local database. - * @param {ConnectDoc} connectRemote Connect the document to a remote sync connection. - * @return {SyncProvider} Sync provider. - */ -export const createSyncProvider = ( connectLocal, connectRemote ) => { - /** - * @type {Record} - */ - const config = {}; - - /** - * @type {Recordvoid>>} - */ - const listeners = {}; - - /** - * @type {Record>} - */ - const docs = {}; - - /** - * Registers an object type. - * - * @param {ObjectType} objectType Object type to register. - * @param {ObjectConfig} objectConfig Object config. - */ - function register( objectType, objectConfig ) { - config[ objectType ] = objectConfig; - } - - /** - * Fetch data from local database or remote source. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - * @param {Function} handleChanges Callback to call when data changes. - */ - async function bootstrap( objectType, objectId, handleChanges ) { - const doc = new Y.Doc(); - docs[ objectType ] = docs[ objectType ] || {}; - docs[ objectType ][ objectId ] = doc; - - const updateHandler = () => { - const data = config[ objectType ].fromCRDTDoc( doc ); - handleChanges( data ); - }; - doc.on( 'update', updateHandler ); - - // connect to locally saved database. - const destroyLocalConnection = await connectLocal( - objectId, - objectType, - doc - ); - - // Once the database syncing is done, start the remote syncing - if ( connectRemote ) { - await connectRemote( objectId, objectType, doc ); - } - - const loadRemotely = config[ objectType ].fetch; - if ( loadRemotely ) { - loadRemotely( objectId ).then( ( data ) => { - doc.transact( () => { - config[ objectType ].applyChangesToDoc( doc, data ); - } ); - } ); - } - - listeners[ objectType ] = listeners[ objectType ] || {}; - listeners[ objectType ][ objectId ] = () => { - destroyLocalConnection(); - doc.off( 'update', updateHandler ); - }; - } - - /** - * Fetch data from local database or remote source. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - * @param {any} data Updates to make. - */ - async function update( objectType, objectId, data ) { - const doc = docs[ objectType ][ objectId ]; - if ( ! doc ) { - throw 'Error doc ' + objectType + ' ' + objectId + ' not found'; - } - doc.transact( () => { - config[ objectType ].applyChangesToDoc( doc, data ); - } ); - } - - /** - * Stop updating a document and discard it. - * - * @param {ObjectType} objectType Object type to load. - * @param {ObjectID} objectId Object ID to load. - */ - async function discard( objectType, objectId ) { - if ( listeners?.[ objectType ]?.[ objectId ] ) { - listeners[ objectType ][ objectId ](); - } - } - - return { - register, - bootstrap, - update, - discard, - }; -}; diff --git a/packages/sync/src/provider.ts b/packages/sync/src/provider.ts new file mode 100644 index 00000000000000..51d998e6581b95 --- /dev/null +++ b/packages/sync/src/provider.ts @@ -0,0 +1,446 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; +import { Awareness } from 'y-protocols/awareness'; + +/** + * Internal dependencies + */ +import { + CRDT_DOC_VERSION, + CRDT_RECORD_MAP_KEY as RECORD_KEY, + CRDT_STATE_MAP_KEY as STATE_KEY, + CRDT_STATE_PERSISTED_AT_KEY as PERSISTED_AT_KEY, + CRDT_STATE_PERSISTED_BY_KEY as PERSISTED_BY_KEY, + CRDT_STATE_RESTORED_AT_KEY as RESTORED_AT_KEY, + CRDT_STATE_RESTORED_BY_KEY as RESTORED_BY_KEY, + LOCAL_SYNC_PROVIDER_ORIGIN, +} from './config'; +import type { + ConnectDoc, + ConnectDocResult, + CRDTDoc, + EntityID, + ObjectID, + ObjectData, + ObjectType, + SyncConfig, + RecordHandlers, +} from './types'; +import { UndoManager } from './undo-manager'; +import { createYjsDoc } from './utils'; + +interface EntityState { + awareness?: Awareness; + discard: () => void; + handlers: RecordHandlers; + objectId: ObjectID; + syncConfig: SyncConfig; + ydoc: CRDTDoc; +} + +/** + * SyncProvider manages the lifecycle of syncing entity records. It establishes + * connections, creates the awareness instance, and coordinates with the local + * store. + */ +export class SyncProvider { + private connectionCreators: ConnectDoc[]; + + protected entityStates: Map< EntityID, EntityState > = new Map(); + + /** + * Constructor. + * + * @param {ConnectDoc[]} connectionCreators Functions that create Yjs connection providers. + */ + public constructor( connectionCreators: ConnectDoc[] = [] ) { + this.connectionCreators = connectionCreators; + } + + /** + * Bootstrap an entity for syncing and manage its lifecycle. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} rawRecord Raw entity record representing this object type. + * @param {RecordHandlers} handlers Handlers for updating and fetching the record. + */ + public async bootstrap( + syncConfig: SyncConfig, + rawRecord: ObjectData, + handlers: RecordHandlers + ): Promise< void > { + if ( 0 === this.connectionCreators.length ) { + return; // No connection creators, so syncing is disabled. + } + + const objectId = syncConfig.getObjectId( rawRecord ); + const objectType = syncConfig.objectType; + const entityId = this.getEntityId( objectType, objectId ); + + if ( this.entityStates.has( entityId ) ) { + return; // Already bootstrapped. + } + + const now = Date.now(); + const ydoc = createYjsDoc( { objectType } ); + const recordMap = ydoc.getMap( RECORD_KEY ); + const stateMap = ydoc.getMap( STATE_KEY ); + + // Clean up connections and in-memory state when the entity is discarded. + const onDiscard = (): void => { + connections.forEach( ( result ) => result.destroy() ); + recordMap.unobserveDeep( onRecordUpdate ); + stateMap.unobserve( onStateUpdate ); + ydoc.destroy(); + this.entityStates.delete( entityId ); + }; + + // When the CRDT document is updated by an UndoManager or a connection (not + // a local origin), update the local store. + const onRecordUpdate = ( + _events: Y.YEvent< any >[], + transaction: Y.Transaction + ): void => { + if ( + transaction.local && + ! ( transaction.origin instanceof Y.UndoManager ) + ) { + return; + } + + void this.updateEntityRecord( objectType, objectId ); + }; + + const onStateUpdate = ( + event: Y.YMapEvent< unknown >, + transaction: Y.Transaction + ) => { + if ( transaction.local ) { + return; + } + + if ( ! event.keysChanged.has( PERSISTED_AT_KEY ) ) { + return; + } + + const newValue = stateMap.get( PERSISTED_AT_KEY ); + if ( 'number' === typeof newValue && newValue > now ) { + handlers.refetchPersistedRecord(); + } + }; + + const entityState: EntityState = { + discard: onDiscard, + handlers, + objectId, + syncConfig, + ydoc, + }; + + if ( syncConfig.supports?.awareness ) { + entityState.awareness = new Awareness( ydoc ); + } + + if ( syncConfig.supports?.undo ) { + UndoManager.create().addToScope( recordMap ); + } + + this.entityStates.set( entityId, entityState ); + + const connections = await this.connect( entityState ); + + // Attach observers. + recordMap.observeDeep( onRecordUpdate ); + stateMap.observe( onStateUpdate ); + + // Get the initial document state. + const initialDoc = await this.getInitialCRDTDoc( + syncConfig, + rawRecord + ); + const initialDocIsInvalid = + true === initialDoc?.meta?.get( 'invalidated' ); + + // Apply the initial document to the current document as a singular update. + if ( initialDoc ) { + ydoc.transact( () => { + Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); + }, LOCAL_SYNC_PROVIDER_ORIGIN ); + } + + // Otherwise, apply changes from the current entity record to the document. + if ( ! initialDoc || initialDocIsInvalid ) { + ydoc.transact( () => { + syncConfig.applyChangesToCRDTDoc( + ydoc, + syncConfig.getInitialObjectData( rawRecord ), + rawRecord, + LOCAL_SYNC_PROVIDER_ORIGIN + ); + + // Only mark as restored if we loaded an initial document. + if ( initialDoc ) { + stateMap.set( RESTORED_AT_KEY, Date.now() ); + stateMap.set( RESTORED_BY_KEY, ydoc.clientID ); + } + }, LOCAL_SYNC_PROVIDER_ORIGIN ); + + // If the entity supports CRDT persistence and the initial document was + // invalidated, save the record to persist the updated document. This + // prevents a newly joining peer (or refreshing user) from re-initializing + // the CRDT document (the "initialization problem"). + if ( initialDocIsInvalid && syncConfig.supports?.crdtPersistence ) { + // TODO: Not every entity has an ID. We need a better way to mark the + // edited record as dirty. + handlers.editRecord( { id: rawRecord.id } ); + handlers.saveRecord(); + } + } + } + + /** + * Establish connections for the given entity and its Yjs document. + * + * @param {EntityState} entityState State for the entity. + */ + private async connect( + entityState: EntityState + ): Promise< ConnectDocResult[] > { + return await Promise.all( + this.connectionCreators?.map( ( create ) => + create( + entityState.objectId, + entityState.syncConfig.objectType, + entityState.ydoc, + entityState.awareness + ) + ) + ); + } + + /** + * Stop syncing an entity and destroy its in-memory state. + * + * @param {ObjectType} objectType Object type to discard. + * @param {ObjectID} objectId Object ID to discard. + */ + public discard( objectType: ObjectType, objectId: ObjectID ): void { + this.entityStates + .get( this.getEntityId( objectType, objectId ) ) + ?.discard(); + } + + /** + * Get the entity ID for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + */ + protected getEntityId( + objectType: ObjectType, + objectId: ObjectID + ): EntityID { + return `${ objectType }_${ objectId }`; + } + + /** + * Get the CRDTDoc that represents the initial state of the object data. Custom + * sync providers can override this method to provide a custom initial state. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} rawRecord Initial data to apply to the document. + */ + private async getInitialCRDTDoc( + syncConfig: SyncConfig, + rawRecord: ObjectData + ): Promise< CRDTDoc | null > { + if ( ! syncConfig.supports?.crdtPersistence ) { + return null; + } + + // Load the persisted document from previous sessions. + const persistedDoc = await this.getPersistedCRDTDoc( + syncConfig, + rawRecord + ); + + // If it exists and matches the current version, apply it as the base state + // of the initial document. + if ( ! persistedDoc ) { + return null; + } + + const stateMap = persistedDoc.getMap( STATE_KEY ); + + if ( CRDT_DOC_VERSION !== stateMap.get( 'version' ) ) { + // TODO: Implement version migration. We have not yet incremented the + // version number, so there is nothing to implement yet. + // + // invalidated=true indicates that the migration was not possible. + persistedDoc.meta?.set( 'invalidated', true ); + } + + return persistedDoc; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + + /** + * Create meta for the entity, e.g., to persist the CRDT doc against the + * entity. Custom sync providers can override this method to provide their + * implementation. + * + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _rawRecord Raw record representing this object type. + * @return {Promise< Record< string, any > >} Entity meta. + */ + public async createEntityMeta( + _syncConfig: SyncConfig, + _rawRecord: ObjectData + ): Promise< Record< string, any > > { + return Promise.resolve( {} ); + } + + /** + * Get the persisted CRDT document from the object data, e.g., from meta. + * Custom sync providers can override this method to provide their + * implementation. + * + * There are 5 possible states: + * + * 1. No persisted document exists: return null. A new document will be created + * from the current entity record. + * + * 2. A persisted document exists with a different version: return it. The + * version mismatch will be detected and the document will be migrated. + * + * 3. A persisted document exists, but its content no longer matches the + * current entity record (i.e., the entity record was updated outside of + * the block editor): return it, but mark it as invalidated. The document + * will be used as the base document and the current entity record will be + * applied as an update. + * + * - Mark it as invalidated by setting `invalidated=true` on its meta map. + * + * 4. A persisted document exists, but the entity record has been restored + * from a revision. This is a special case of #3, but is handled + * identically. + * + * 5. A persisted document exists: return it. It will be used as the initial + * document. + * + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _rawRecord Record representing this object type. + * @return {Promise< CRDTDoc | null >} The persisted CRDT document, or null if none exists. + */ + protected async getPersistedCRDTDoc( + _syncConfig: SyncConfig, + _rawRecord: ObjectData + ): Promise< CRDTDoc | null > { + return Promise.resolve( null ); + } + + /* eslint-enable @typescript-eslint/no-unused-vars */ + + /** + * Get the undo manager. + * + * @return {UndoManager | null} The undo manager, or null if unsupported. + */ + public getUndoManager(): UndoManager | null { + return UndoManager.create() ?? null; + } + + /** + * Update CRDT document with changes from the local store. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} rawRecord Raw record to load. + * @param {Partial< ObjectData >} changes Updates to make. + * @param {string} origin The source of change. + */ + public updateCRDTDoc( + syncConfig: SyncConfig, + rawRecord: ObjectData, + changes: Partial< ObjectData >, + origin: string + ): void { + const objectType = syncConfig.objectType; + const objectId = syncConfig.getObjectId( rawRecord ); + const entityId = this.getEntityId( objectType, objectId ); + const ydoc = this.entityStates.get( entityId )?.ydoc; + + ydoc?.transact( () => { + syncConfig.applyChangesToCRDTDoc( + ydoc, + changes, + rawRecord, + origin + ); + }, origin ); + } + + /** + * Update the entity record in the local store with changes from the CRDT + * document. + * + * @param {ObjectType} objectType Object type of record to update. + * @param {ObjectID} objectId Object ID of record to update. + */ + private async updateEntityRecord( + objectType: ObjectType, + objectId: ObjectID + ): Promise< void > { + const entityId = this.getEntityId( objectType, objectId ); + const entityState = this.entityStates.get( entityId ); + + if ( ! entityState ) { + return; + } + + const { handlers, syncConfig, ydoc } = entityState; + + const currentRecord = await handlers.getEditedRecord(); + + // Determine which synced properties have actually changed by comparing + // them against the current entity record. + const changes = syncConfig.getChangesFromCRDTDoc( ydoc, currentRecord ); + + // This is a good spot to debug to see which changes are being synced. Note + // that `blocks` will always appear in the changes, but will only result + // in an update to the store if the blocks have changed. + + handlers.editRecord( changes ); + } + + /** + * Update the last persisted timestamp in the CRDT document state map. This is + * used by peers as a signal that they need to refetch the persisted entity. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} rawRecord Raw record representing this object type. + */ + public updateLastPersistedDate( + syncConfig: SyncConfig, + rawRecord: ObjectData + ): void { + const objectId = syncConfig.getObjectId( rawRecord ); + const objectType = syncConfig.objectType; + const entityId = this.getEntityId( objectType, objectId ); + const entityState = this.entityStates.get( entityId ); + + if ( ! entityState ) { + return; + } + + const ydoc = entityState.ydoc; + + ydoc.transact( () => { + const stateMap = ydoc.getMap( STATE_KEY ); + stateMap.set( PERSISTED_AT_KEY, Date.now() ); + stateMap.set( PERSISTED_BY_KEY, ydoc.clientID ); + }, LOCAL_SYNC_PROVIDER_ORIGIN ); + } +} diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 03439ecf280319..bad92da2d10b5e 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -1,27 +1,51 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; +import type { Awareness } from 'y-protocols/awareness'; + +export type CRDTDoc = Y.Doc; +export type EntityID = string; export type ObjectID = string; export type ObjectType = string; -export type ObjectData = any; -export type CRDTDoc = any; -export type ObjectConfig = { - fetch: ( id: ObjectID ) => Promise< ObjectData >; - applyChangesToDoc: ( doc: CRDTDoc, data: any ) => void; - fromCRDTDoc: ( doc: CRDTDoc ) => any; -}; +// Object data represents any entity record, post, term, user, site, etc. There +// are not many expectations that can hold on its shape. +export interface ObjectData extends Record< string, unknown > {} + +export interface ConnectDocResult { + destroy: () => void; +} export type ConnectDoc = ( id: ObjectID, type: ObjectType, - doc: CRDTDoc -) => Promise< () => void >; + ydoc: Y.Doc, + awareness?: Awareness +) => Promise< ConnectDocResult >; + +export interface RecordHandlers { + editRecord: ( data: Partial< ObjectData > ) => void; + getEditedRecord: () => Promise< ObjectData >; + refetchPersistedRecord: () => void; + saveRecord: () => void; +} -export type SyncProvider = { - register: ( type: ObjectType, config: ObjectConfig ) => void; - bootstrap: ( - type: ObjectType, - id: ObjectID, - handleChanges: ( data: any ) => void - ) => Promise< CRDTDoc >; - update: ( type: ObjectType, id: ObjectID, data: any ) => void; - discard: ( type: ObjectType, id: ObjectID ) => Promise< CRDTDoc >; -}; +export interface SyncConfig { + applyChangesToCRDTDoc: ( + ydoc: Y.Doc, + changes: Partial< ObjectData >, + rawRecord: ObjectData, + origin: string + ) => void; + getChangesFromCRDTDoc: ( ydoc: Y.Doc, record: ObjectData ) => ObjectData; + getInitialObjectData: ( record: ObjectData ) => ObjectData; + getObjectId: ( data: ObjectData ) => ObjectID; + objectType: ObjectType; + supports?: { + awareness?: boolean; + crdtPersistence?: boolean; + undo?: boolean; + }; + syncedProperties: Set< string >; +} diff --git a/packages/sync/src/undo-manager.ts b/packages/sync/src/undo-manager.ts new file mode 100644 index 00000000000000..30bb19d5878c46 --- /dev/null +++ b/packages/sync/src/undo-manager.ts @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; + +/** + * WordPress dependencies + */ +import type { + HistoryRecord, + UndoManager as WPUndoManager, +} from '@wordpress/undo-manager'; + +/** + * Internal dependencies + */ +import { LOCAL_EDITOR_ORIGIN } from './config'; +import { YMultiDocUndoManager } from './y-utilities/y-multidoc-undomanager'; +import type { ObjectData } from './types'; + +/** + * Wrapper class that implements the WordPress UndoManager interface while using + * YMultiDocUndoManager internally. This allows undo/redo operations to be + * transacted against multiple CRDT documents (one per entity) and giving each + * peer their own undo/redo stack without conflicts. + */ +export class UndoManager implements WPUndoManager< ObjectData > { + private static instance: UndoManager; + private undoManager: YMultiDocUndoManager; + + private constructor() { + this.undoManager = new YMultiDocUndoManager( [], { + // Throttle undo/redo captures. (default: 500ms) + captureTimeout: 200, + // Ensure that we only scope the undo/redo to the current editor. + // The yjs document's clientID is added once it's available. + trackedOrigins: new Set( [ LOCAL_EDITOR_ORIGIN ] ), + } ); + } + + public static create(): UndoManager { + if ( ! UndoManager.instance ) { + UndoManager.instance = new UndoManager(); + } + + return UndoManager.instance; + } + + /** + * Record changes into the history. + * Since Yjs automatically tracks changes, this method translates the WordPress + * HistoryRecord format into Yjs operations. + * + * @param _record A record of changes to record. + * @param _isStaged Whether to immediately create an undo point or not. + */ + public addRecord( + _record?: HistoryRecord< ObjectData >, + _isStaged = false // eslint-disable-line @typescript-eslint/no-unused-vars + ): void { + // This is a no-op for Yjs since it automatically tracks changes. + // If needed, we could implement custom logic to handle specific records. + } + + /** + * Add a Yjs map to the scope of the undo manager. + * + * @param {Y.Map< any >} ymap The Yjs map to add to the scope. + */ + public addToScope( ymap: Y.Map< any > ): void { + this.undoManager.addToScope( ymap ); + } + + /** + * Undo the last recorded changes. + * + */ + public undo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasUndo() ) { + return; + } + + // Perform the undo operation + this.undoManager.undo(); + + // Intentionally return an empty array, because the SyncProvider will update + // the entity record based on the Yjs document changes. + return []; + } + + /** + * Redo the last undone changes. + */ + public redo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasRedo() ) { + return; + } + + // Perform the redo operation + this.undoManager.redo(); + + // Intentionally return an empty array, because the SyncProvider will update + // the entity record based on the Yjs document changes. + return []; + } + + /** + * Check if there are changes that can be undone. + * + * @return Whether there are changes to undo. + */ + public hasUndo(): boolean { + return this.undoManager.canUndo(); + } + + /** + * Check if there are changes that can be redone. + * + * @return Whether there are changes to redo. + */ + public hasRedo(): boolean { + return this.undoManager.canRedo(); + } +} diff --git a/packages/sync/src/utils.ts b/packages/sync/src/utils.ts new file mode 100644 index 00000000000000..1ffe33bed894d5 --- /dev/null +++ b/packages/sync/src/utils.ts @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import { + CRDT_DOC_VERSION, + CRDT_STATE_MAP_KEY, + CRDT_STATE_PERSISTED_AT_KEY, + CRDT_STATE_RESTORED_AT_KEY, + CRDT_STATE_VERSION_KEY, +} from './config'; + +export function createYjsDoc( documentMeta: Record< string, unknown > ): Y.Doc { + // Meta is not synced and does not get persisted with the document. + const metaMap = new Map< string, unknown >( + Object.entries( documentMeta ) + ); + + const ydoc = new Y.Doc( { meta: metaMap } ); + const stateMap = ydoc.getMap( CRDT_STATE_MAP_KEY ); + + stateMap.set( CRDT_STATE_PERSISTED_AT_KEY, 0 ); + stateMap.set( CRDT_STATE_RESTORED_AT_KEY, 0 ); + stateMap.set( CRDT_STATE_VERSION_KEY, CRDT_DOC_VERSION ); + + return ydoc; +} diff --git a/packages/sync/src/y-utilities/LICENSE b/packages/sync/src/y-utilities/LICENSE new file mode 100644 index 00000000000000..2988b7d57a2dc8 --- /dev/null +++ b/packages/sync/src/y-utilities/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2022 Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sync/src/y-utilities/y-multidoc-undomanager.js b/packages/sync/src/y-utilities/y-multidoc-undomanager.js new file mode 100644 index 00000000000000..ce1db65ad6318c --- /dev/null +++ b/packages/sync/src/y-utilities/y-multidoc-undomanager.js @@ -0,0 +1,190 @@ +// File copied as is from the y-utilities package. +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +// @ts-nocheck +/* eslint-env browser */ + +import * as array from 'lib0/array' +import * as map from 'lib0/map' +import { Observable } from 'lib0/observable' +import * as Y from 'yjs' + +/** + * @param {YMultiDocUndoManager} mum + * @param {'undo' | 'redo'} type + */ +const popStackItem = (mum, type) => { + const stack = type === 'undo' ? mum.undoStack : mum.redoStack + while (stack.length > 0) { + const um = /** @type {Y.UndoManager} */ (stack.pop()) + const prevUmStack = type === 'undo' ? um.undoStack : um.redoStack + const stackItem = /** @type {any} */ (prevUmStack.pop()) + let actionPerformed = false + if (type === 'undo') { + um.undoStack = [stackItem] + actionPerformed = um.undo() !== null + um.undoStack = prevUmStack + } else { + um.redoStack = [stackItem] + actionPerformed = um.redo() !== null + um.redoStack = prevUmStack + } + if (actionPerformed) { + return stackItem + } + } + return null +} + +/** + * @extends Observable + */ +export class YMultiDocUndoManager extends Observable { + /** + * @param {Y.AbstractType|Array>} typeScope Accepts either a single type, or an array of types + * @param {ConstructorParameters[1]} opts + */ + constructor (typeScope = [], opts = {}) { + super() + /** + * @type {Map} + */ + this.docs = new Map() + this.trackedOrigins = opts.trackedOrigins || new Set([null]) + opts.trackedOrigins = this.trackedOrigins + this._defaultOpts = opts + /** + * @type {Array} + */ + this.undoStack = [] + /** + * @type {Array} + */ + this.redoStack = [] + this.addToScope(typeScope) + } + + /** + * @param {Array> | Y.AbstractType} ytypes + */ + addToScope (ytypes) { + ytypes = array.isArray(ytypes) ? ytypes : [ytypes] + ytypes.forEach(ytype => { + const ydoc = /** @type {Y.Doc} */ (ytype.doc) + const um = map.setIfUndefined(this.docs, ydoc, () => { + const um = new Y.UndoManager([ytype], this._defaultOpts) + um.on('stack-cleared', /** @param {any} opts */ ({ undoStackCleared, redoStackCleared }) => { + this.clear(undoStackCleared, redoStackCleared) + }) + ydoc.on('destroy', () => { + this.docs.delete(ydoc) + this.undoStack = this.undoStack.filter(um => um.doc !== ydoc) + this.redoStack = this.redoStack.filter(um => um.doc !== ydoc) + }) + um.on('stack-item-added', /** @param {any} change */ change => { + const stack = change.type === 'undo' ? this.undoStack : this.redoStack + stack.push(um) + this.emit('stack-item-added', [{ ...change, ydoc: ydoc }, this]) + }) + um.on('stack-item-updated', /** @param {any} change */ change => { + this.emit('stack-item-updated', [{ ...change, ydoc }, this]) + }) + um.on('stack-item-popped', /** @param {any} change */ change => { + this.emit('stack-item-popped', [{ ...change, ydoc }, this]) + }) + // if doc is destroyed + // emit events from um to multium + return um + }) + /* c8 ignore next 4 */ + if (um.scope.every(yt => yt !== ytype)) { + um.scope.push(ytype) + } + }) + } + + /** + * @param {any} origin + */ + /* c8 ignore next 3 */ + addTrackedOrigin (origin) { + this.trackedOrigins.add(origin) + } + + /** + * @param {any} origin + */ + /* c8 ignore next 3 */ + removeTrackedOrigin (origin) { + this.trackedOrigins.delete(origin) + } + + /** + * Undo last changes on type. + * + * @return {any?} Returns StackItem if a change was applied + */ + undo () { + return popStackItem(this, 'undo') + } + + /** + * Redo last undo operation. + * + * @return {any?} Returns StackItem if a change was applied + */ + redo () { + return popStackItem(this, 'redo') + } + + clear (clearUndoStack = true, clearRedoStack = true) { + /* c8 ignore next */ + if ((clearUndoStack && this.canUndo()) || (clearRedoStack && this.canRedo())) { + this.docs.forEach(um => { + /* c8 ignore next */ + clearUndoStack && (this.undoStack = []) + /* c8 ignore next */ + clearRedoStack && (this.redoStack = []) + um.clear(clearUndoStack, clearRedoStack) + }) + this.emit('stack-cleared', [{ undoStackCleared: clearUndoStack, redoStackCleared: clearRedoStack }]) + } + } + + /* c8 ignore next 5 */ + stopCapturing () { + this.docs.forEach(um => { + um.stopCapturing() + }) + } + + /** + * Are undo steps available? + * + * @return {boolean} `true` if undo is possible + */ + canUndo () { + return this.undoStack.length > 0 + } + + /** + * Are redo steps available? + * + * @return {boolean} `true` if redo is possible + */ + canRedo () { + return this.redoStack.length > 0 + } + + destroy () { + this.docs.forEach(um => um.destroy()) + super.destroy() + } +} + +/** + * @todo remove + * @deprecated Use YMultiDocUndoManager instead + */ +export const MultiDocUndoManager = YMultiDocUndoManager diff --git a/phpunit/script-dependencies-test.php b/phpunit/script-dependencies-test.php index 75064dd49cef89..e0f7f628d715c3 100644 --- a/phpunit/script-dependencies-test.php +++ b/phpunit/script-dependencies-test.php @@ -42,9 +42,9 @@ public function test_polyfill_dependents() { 'wp-block-library', 'wp-blocks', 'wp-edit-site', - 'wp-core-data', 'wp-editor', 'wp-router', + 'wp-sync', 'wp-url', 'wp-widgets', ); diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index c99c25ee0127ce..f847d67e01f455 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -39,7 +39,6 @@ const BUNDLED_PACKAGES = [ '@wordpress/dataviews/wp', '@wordpress/icons', '@wordpress/interface', - '@wordpress/sync', '@wordpress/undo-manager', '@wordpress/upload-media', '@wordpress/fields',