diff --git a/lib/experimental/synchronization.php b/lib/experimental/synchronization.php index 87f13a7c685a59..029d47c8a365a9 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 a 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 a2032eb99abdc5..0ef9fff443432a 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 ec4790fb459c24..c2849c4a831595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27529,11 +27529,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", @@ -31087,13 +31082,15 @@ } }, "node_modules/lib0": { - "version": "0.2.79", - "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.79.tgz", - "integrity": "sha512-fIdPbxzMVq10wt3ou1lp3/f9n5ciHZ6t+P1vyGy3XXr018AntTYM4eg24sNFcNq8SYDQwmhhoGdS58IlYBzfBw==", + "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" }, @@ -48413,56 +48410,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", @@ -48595,22 +48542,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", @@ -50135,6 +50066,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", @@ -50147,6 +50079,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" }, @@ -52077,22 +52010,127 @@ "version": "1.29.0", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@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", + "y-webrtc": "^10.3.0", + "yjs": "13.6.27" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, + "packages/sync/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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/y-webrtc": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz", + "integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42", + "simple-peer": "^9.11.0", + "y-protocols": "^1.0.6" + }, + "bin": { + "y-webrtc-signaling": "bin/server.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "optionalDependencies": { + "ws": "^8.14.2" + }, + "peerDependencies": { + "yjs": "^13.6.8" + } + }, + "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.29.0", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index a6ead7ba3399fe..078e66de8fc011 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 f6b245bb525198..1aa563e64c3890 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -400,39 +400,37 @@ export const editEntityRecord = }; if ( window.__experimentalEnableSync && entityConfig.syncConfig ) { if ( globalThis.IS_GUTENBERG_PLUGIN ) { - const objectId = entityConfig.getSyncObjectId( recordId ); + // @todo this always updates the Yjs doc, which is undesirable, probably we can read the yjs + // content from the comment tag here getSyncProvider().update( - entityConfig.syncObjectType + '--edit', - objectId, - edit.edits + entityConfig.syncConfig.objectType, + record, + edit.edits, + 'gutenberg' ); } - } 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 ( ! 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, + } ); }; /** @@ -677,6 +675,21 @@ export const saveEntityRecord = ), }; } + if ( + window.__experimentalEnableSync && + entityConfig.syncConfig?.enabled + ) { + // 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', diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index f097f76cb89946..6f2d0d30b18e81 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -8,11 +8,24 @@ import { capitalCase, pascalCase } from 'change-case'; */ import apiFetch from '@wordpress/api-fetch'; import { __ } from '@wordpress/i18n'; -import { RichTextData } from '@wordpress/rich-text'; +import { parse } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { defaultApplyChangesToCRDTDoc } from './utils/crdt'; export const DEFAULT_ENTITY_KEY = 'id'; const POST_RAW_ATTRIBUTES = [ 'title', 'excerpt', 'content' ]; +/** + * @param {Y.Doc} ydoc + * @return {import('@wordpress/sync').ObjectData} The JSON representation of the document. + */ +const defaultFromCRDTDoc = ( ydoc ) => { + return ydoc.getMap( 'document' ).toJSON(); +}; + export const rootEntitiesConfig = [ { label: __( 'Base' ), @@ -40,24 +53,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 +62,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', @@ -272,37 +247,27 @@ 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. * * @return {Promise} Entities promise */ async function loadPostTypeEntities() { + const syncedProperties = new Set( [ + 'blocks', + 'featured_media', + 'format', + 'generated_slug', + 'password', + 'slug', + 'sticky', + 'tags', + 'template', + 'title', + ] ); + 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( @@ -330,39 +295,57 @@ 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`, - } ); - }, - 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 ) - ); - } + enabled: Boolean( + postType.supports?.[ 'collaborative-editing' ] && + postType.supports?.editor + ), + /** + * @param {Y.Doc} ydoc + * @param {Object} changes + * @param {string} origin + */ + applyChangesToCRDTDoc: ( ydoc, changes, origin ) => { + const filteredChanges = Object.fromEntries( + Object.entries( changes ).filter( + ( [ key, value ] ) => + syncedProperties.has( key ) && + 'function' !== typeof value // cannot serialize function values + ) + ); - value = serialisableBlocksCache.get( value ); - } - - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } - } ); + defaultApplyChangesToCRDTDoc( + ydoc, + filteredChanges, + origin + ); }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); + fromCRDTDoc: defaultFromCRDTDoc, + /** + * 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 ) => { + // Mix in the parsed blocks into the record. Only allow properties in + // the synced properties set. + const content = record.content?.raw ?? record.content ?? ''; + const blocks = parse( content ); + + return Object.fromEntries( + Object.entries( { ...record, blocks } ).filter( + ( [ key ] ) => syncedProperties.has( key ) + ) + ); }, + getObjectId: ( { id } ) => id, + objectType: `postType/${ postType.slug }`, + supportsAwareness: true, + supportsUndo: true, }, - syncObjectType: 'postType/' + postType.name, - getSyncObjectId: ( id ) => id, supportsPagination: true, getRevisionsUrl: ( parentId, revisionId ) => `/${ namespace }/${ @@ -408,24 +391,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 6acbe493913a89..23077c961adc75 100644 --- a/packages/core-data/src/private-selectors.ts +++ b/packages/core-data/src/private-selectors.ts @@ -9,6 +9,7 @@ import { createSelector, createRegistrySelector } from '@wordpress/data'; import { getDefaultTemplateId, getEntityRecord, type State } from './selectors'; import { STORE_NAME } from './name'; import { unlock } from './lock-unlock'; +import { getSyncProvider } from './sync'; import logEntityDeprecation from './utils/log-entity-deprecation'; 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 88fbe834053305..1041abd8354ff6 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -73,49 +73,97 @@ export const getEntityRecord = if ( ! entityConfig ) { return; } - const lock = await dispatch.__unstableAcquireStoreLock( STORE_NAME, [ 'entities', 'records', kind, name, key ], { exclusive: false } ); - try { // Entity supports configs, - // use the sync algorithm instead of the old fetch behavior. + if ( query !== undefined && query._fields ) { + // @todo how does this work? What is happening here? + // 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(), + }; + } + + // Disable reason: While true that an early return could leave `path` + // unused, it's important that path is derived using the query prior to + // additional query modifications in the condition below, since those + // modifications are relevant to how the data is tracked in state, and not + // for how the request is made to the REST API. + + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const path = addQueryArgs( + entityConfig.baseURL + ( key ? '/' + key : '' ), + { + ...entityConfig.baseURLParams, + ...query, + } + ); + + if ( query !== undefined && query._fields ) { + query = { ...query, include: [ key ] }; + + // 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 `getEntityRecords` resolution to occur. + const hasRecords = select.hasEntityRecords( kind, name, query ); + if ( hasRecords ) { + return; + } + } + + 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 - ); - } - ); - - // Bootstraps the edited document as well (and load from peers). - await getSyncProvider().bootstrap( - entityConfig.syncObjectType + '--edit', - objectId, - ( record ) => { + entityConfig.syncConfig, + record, + ( edits ) => { dispatch( { type: 'EDIT_ENTITY_RECORD', kind, name, recordId: key, - edits: record, + edits, meta: { undo: undefined, }, @@ -123,89 +171,13 @@ export const getEntityRecord = } ); } - } 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(), - }; - } - - // Disable reason: While true that an early return could leave `path` - // unused, it's important that path is derived using the query prior to - // additional query modifications in the condition below, since those - // modifications are relevant to how the data is tracked in state, and not - // for how the request is made to the REST API. - - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const path = addQueryArgs( - entityConfig.baseURL + ( key ? '/' + key : '' ), - { - ...entityConfig.baseURLParams, - ...query, - } - ); - - if ( query !== undefined && query._fields ) { - query = { ...query, include: [ key ] }; - - // 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 `getEntityRecords` resolution to occur. - const hasRecords = select.hasEntityRecords( - kind, - name, - query - ); - if ( hasRecords ) { - return; - } - } - - 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 76eb3889e80cbc..7265db7c209bb7 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, @@ -1071,7 +1072,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(); } /** @@ -1083,7 +1084,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..b52faff570dc4c --- /dev/null +++ b/packages/core-data/src/sync.ts @@ -0,0 +1,47 @@ +/** + * 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; + } + + const fallbackNoOpSyncProvider = new SyncProvider( null, null ); + + 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 the fallback no-op sync provider. + if ( ! syncProvider ) { + syncProvider = fallbackNoOpSyncProvider; + } + + 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..aaf8cb6ddf4711 --- /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.getInitialCRDTDoc' 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..3ccbd67cafd0ab --- /dev/null +++ b/packages/core-data/src/utils/crdt.ts @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import * as fun from 'lib0/function'; + +/** + * WordPress dependencies + */ +import { type CRDTDoc, Y } from '@wordpress/sync'; + +/** + * Internal dependencies + */ +import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; + +type PrimitiveValue = string | number | boolean | null | undefined; + +interface PostChanges { + blocks?: Y.Array< YBlock > | Block[]; + title?: string | { raw: string }; +} + +export function defaultApplyChangesToCRDTDoc( + ydoc: CRDTDoc, + changes: PostChanges, + origin: string +): void { + const ymap = ydoc.getMap( 'document' ); + + Object.entries( changes ).forEach( ( [ key, newValue ] ) => { + // Return .get() result so that caller can operate on the data type + // without having to call .get() themselves. + function setValue< T = unknown >( updatedValue: T ): T { + ymap.set( key, updatedValue ); + return ymap.get( key ) as T; + } + + switch ( key ) { + case 'blocks': { + let currentBlocks = ymap.get( 'blocks' ) as Y.Array< YBlock >; + + if ( ! ( currentBlocks instanceof Y.Array ) ) { + currentBlocks = setValue< Y.Array< YBlock > >( + new Y.Array() + ); // Initialize + } + + // Block[] from local changes or Y.Array< Y.Map > from peer. + const newBlocks = newValue ?? []; + + // Merge blocks does not need `setValue` because it has been + // called above and the result can be operated on directly. + mergeCrdtBlocks( currentBlocks, newBlocks, origin ); + break; + } + + case 'title': { + const currentValue = ymap.get( + 'title' + ) as PostChanges[ 'title' ]; + + // Copy logic from prePersistPostType to ensure that the "Auto + // Draft" template title is not synced. + let rawNewValue = newValue?.raw ?? newValue; + if ( ! currentValue && 'Auto Draft' === rawNewValue ) { + rawNewValue = ''; + } + + mergePrimitiveValue( currentValue, rawNewValue, setValue ); + break; + } + + // Add support for additional data types here. + + default: { + const currentValue = ymap.get( key ); + mergePrimitiveValue( currentValue, newValue, setValue ); + } + } + } ); +} + +export function mergePrimitiveValue< ValueType extends PrimitiveValue >( + currentValue: ValueType, + newValue: ValueType, + setValue: ( value: ValueType ) => ValueType +): void { + if ( ! fun.equalityDeep( currentValue, newValue ) ) { + setValue( newValue ); + } +} 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/sync/CODE.md b/packages/sync/CODE.md index 40a4b76d2cfd42..d61dac12824e89 100644 --- a/packages/sync/CODE.md +++ b/packages/sync/CODE.md @@ -1,6 +1,6 @@ # 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 is part of an ongoing effort to lay the groundwork of Real-Time Collaboration in Gutenberg. Relevant docs: @@ -8,9 +8,27 @@ Relevant docs: - https://github.com/WordPress/gutenberg/issues/52593 - https://docs.yjs.dev/ -## Enable the experiment +## Enable the experiments -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: +There are two experiments that must be enabled in the "Guteberg > Experiments" page: + +- Collaboration: enable real-time collaboration +- Collaboration: WebRTC provider + +Alternatively, you can enable just the "Collaboration: enable real-time collaboration" experiment and load a custom provider (transport) via a filter: + +```js +addFilter( 'core.getSyncProvider', 'my-plugin/custom-sync-provider', ( getSyncProvider ) => { + return { + // Custom sync provider implementation + bootstrap: () => {}, + discard: () => {}, + update: () => {}, + }; +} ); +``` + +When it is enabled (search for `gutenberg-sync-collaboration` in the codebase), the client receives two new pieces of data: - `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. diff --git a/packages/sync/README.md b/packages/sync/README.md index f15d61b5a1eb5f..ac18815fcbbea1 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -47,13 +47,19 @@ Function that creates a new WebRTC Connection. _Parameters_ -- _config_ `Object`: The object ID. -- _config.signaling_ `Array`: -- _config.password_ `string`: +- _config_ `WebRTCConnectionConfig`: Configuration for the WebRTC connection. _Returns_ -- `Function`: Promise that resolves when the connection is established. +- `ConnectDoc`: Promise that resolves when the connection is established. + +### getWebRTCSyncProvider + +Returns a WebRTC sync provider. This is the curent default sync provider. + +_Returns_ + +- `SyncProvider`: The WebRTC sync provider. diff --git a/packages/sync/package.json b/packages/sync/package.json index 6dac3bd2f7532b..9c53e905e2987a 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -29,16 +29,15 @@ "types": "build-types", "sideEffects": false, "dependencies": { - "@babel/runtime": "7.25.7", "@types/simple-peer": "^9.11.5", + "@wordpress/hooks": "file:../hooks", "@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", + "y-webrtc": "^10.3.0", + "yjs": "13.6.27" }, "publishConfig": { "access": "public" 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 deleted file mode 100644 index 97fcddc727d024..00000000000000 --- a/packages/sync/src/create-webrtc-connection.js +++ /dev/null @@ -1,39 +0,0 @@ -/** - * External dependencies - */ -// import { WebrtcProvider } from 'y-webrtc'; - -/** - * Internal dependencies - */ -import { WebrtcProviderWithHttpSignaling } from './webrtc-http-stream-signaling'; - -/** @typedef {import('./types').ObjectType} ObjectType */ -/** @typedef {import('./types').ObjectID} ObjectID */ -/** @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. - */ -export function createWebRTCConnection( { signaling, password } ) { - return function ( - /** @type {string} */ objectId, - /** @type {string} */ objectType, - /** @type {import("yjs").Doc} */ doc - ) { - const roomName = `${ objectType }-${ objectId }`; - new WebrtcProviderWithHttpSignaling( roomName, doc, { - signaling, - // @ts-ignore - password, - } ); - - return Promise.resolve( () => true ); - }; -} diff --git a/packages/sync/src/create-webrtc-connection.ts b/packages/sync/src/create-webrtc-connection.ts new file mode 100644 index 00000000000000..2ea51777e8a91e --- /dev/null +++ b/packages/sync/src/create-webrtc-connection.ts @@ -0,0 +1,42 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { WebrtcProviderWithHttpSignaling } from './webrtc-http-stream-signaling'; +import type { ConnectDoc, CRDTDoc, ObjectID, ObjectType } from './types'; + +export interface WebRTCConnectionConfig { + signaling: string[]; + password?: string; +} + +/** + * Function that creates a new WebRTC Connection. + * + * @param {WebRTCConnectionConfig} config Configuration for the WebRTC connection. + * @return {ConnectDoc} Promise that resolves when the connection is established. + */ +export function createWebRTCConnection( { + signaling, + password, +}: WebRTCConnectionConfig ): ConnectDoc { + return function ( + objectId: ObjectID, + objectType: ObjectType, + doc: CRDTDoc + ) { + const roomName = `${ objectType }-${ objectId }`; + new WebrtcProviderWithHttpSignaling( roomName, doc, { + signaling, + // @ts-ignore + password, + } ); + + return Promise.resolve( { + 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..ec0341e9dc44f0 --- /dev/null +++ b/packages/sync/src/index.ts @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ + +/** + * Internal dependencies + */ +import { connectIndexDb } from './connect-indexdb'; +import { createWebRTCConnection } from './create-webrtc-connection'; +import { SyncProvider } from './provider'; + +export * as Y from 'yjs'; +export { connectIndexDb } from './connect-indexdb'; +export { createWebRTCConnection } from './create-webrtc-connection'; +export { CRDT_DOC_VERSION, 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..35e930b8fc5e09 --- /dev/null +++ b/packages/sync/src/provider.ts @@ -0,0 +1,309 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * Internal dependencies + */ +import { UndoManager } from './undo-manager'; +import type { + ConnectDoc, + ConnectDocResult, + CRDTDoc, + EntityID, + ObjectID, + ObjectData, + ObjectType, + SyncConfig, +} from './types'; + +interface EntityState { + destroy: () => void; + ydoc: CRDTDoc; +} + +// 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, if any. +export const CRDT_DOC_VERSION = 1; + +export class SyncProvider { + private connectLocal: ConnectDoc | null; + private connectRemote: ConnectDoc | null; + + /** + * CAUTION: We currently store a single UndoManager instance under these + * assumptions: + * + * 1. Only entities loaded by the block editor support an undo manager. + * 2. Only one such entity is loaded at a time. + * 3. The entity's SyncConfig has `supportsUndo` set to true. + * + * If these assumptions fail, we will need to refactor the selectors provided + * by `@wordpress/core-data` (e.g., `getUndoManager`) to support multiple + * UndoManager instances by requiring the entity type and ID as parameters. + */ + private undoManager: UndoManager | null = null; + + protected configs: Map< ObjectType, SyncConfig > = new Map(); + protected connections: Map< EntityID, ConnectDocResult[] > = new Map(); + protected entityStates: Map< EntityID, EntityState > = new Map(); + + /** + * Constructor. + * + * @param {ConnectDoc | null} connectLocal Connect the document to a local database. + * @param {ConnectDoc | null} connectRemote Connect the document to a remote sync connection. + */ + public constructor( + connectLocal: ConnectDoc | null, + connectRemote: ConnectDoc | null + ) { + this.connectLocal = connectLocal; + this.connectRemote = connectRemote; + } + + /** + * Connect to a document. + * + * @param {ObjectID} objectId Object ID to connect. + * @param {ObjectType} objectType Object type to connect. + * @param {CRDTDoc} ydoc Yjs document for the object. + */ + private async connect( + objectId: ObjectID, + objectType: ObjectType, + ydoc: CRDTDoc + ): Promise< ConnectDocResult[] > { + return ( + await Promise.all( [ + this.connectLocal?.( objectId, objectType, ydoc ), + this.connectRemote?.( objectId, objectType, ydoc ), + ] ) + ).filter( ( result ): result is ConnectDocResult => Boolean( result ) ); + } + + /** + * Fetch data from local database or remote source. + * + * @param {SyncConfig} syncConfig Sync configuration for the object type. + * @param {ObjectData} record Record representing this object type. + * @param {Function} handleChanges Callback to call when data changes. + */ + public async bootstrap( + syncConfig: SyncConfig, + record: ObjectData, + handleChanges: ( data: Partial< ObjectData > ) => void + ): Promise< void > { + const meta = new Map< string, unknown >( [ + [ 'version', CRDT_DOC_VERSION ], + ] ); + const ydoc = new Y.Doc( { meta } ); + const objectId = syncConfig.getObjectId( record ); + const objectType = syncConfig.objectType; + const connections = await this.connect( objectId, objectType, ydoc ); + const entityId = this.getEntityId( objectType, objectId ); + + const onDestroy = (): void => { + connections.forEach( ( result ) => result.destroy() ); + ydoc.off( 'update', onUpdate ); + ydoc.destroy(); + this.entityStates.delete( entityId ); + }; + + const onUpdate = ( _update: Uint8Array, origin: string ): void => { + if ( origin !== 'gutenberg' ) { + const data = syncConfig.fromCRDTDoc( ydoc ); + handleChanges( data ); + } + }; + + ydoc.on( 'update', onUpdate ); + + if ( syncConfig.supportsUndo ) { + this.undoManager = new UndoManager( ydoc ); + } + + this.configs.set( objectType, syncConfig ); + this.connections.set( entityId, connections ); + this.entityStates.set( entityId, { + destroy: onDestroy, + ydoc, + } ); + + // Get the initial document state. + const initialDoc = await this.getInitialCRDTDoc( syncConfig, record ); + + // Apply the initial document to the current document as a singular update. + Y.transact( + ydoc, + () => { + Y.applyUpdate( ydoc, Y.encodeStateAsUpdate( initialDoc ) ); + }, + 'syncProvider.bootstrap', + false + ); + } + + /** + * 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 entity state for the given object type and object ID. + * + * @param {ObjectType} objectType Object type. + * @param {ObjectID} objectId Object ID. + */ + protected getEntityState( + objectType: ObjectType, + objectId: ObjectID + ): EntityState | null { + return ( + this.entityStates.get( this.getEntityId( objectType, objectId ) ) ?? + null + ); + } + + /** + * 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} record Initial data to apply to the document. + */ + private async getInitialCRDTDoc( + syncConfig: SyncConfig, + record: ObjectData + ): Promise< CRDTDoc > { + // IMPORTANT: We use a new Yjs document so that the initial state can be + // applied to the "real" Yjs document as a singular update. Therefore, we + // don't need to wrap the changes in a transaction. + const initialStateDoc = new Y.Doc(); + + // Load the persisted document from previous sessions. + const persistedDoc = await this.getPersistedCRDTDoc( + syncConfig, + record, + CRDT_DOC_VERSION + ); + + // If it exists and matches the current version, apply it as the base state + // of the initial document. + if ( + persistedDoc && + CRDT_DOC_VERSION === persistedDoc.meta?.get( 'version' ) + ) { + Y.applyUpdate( + initialStateDoc, + Y.encodeStateAsUpdate( persistedDoc ) + ); + } + + const initialData = syncConfig.getInitialObjectData( record ); + syncConfig.applyChangesToCRDTDoc( + initialStateDoc, + initialData, + 'syncProvider.getInitialCRDTDoc' + ); + + return initialStateDoc; + } + + /* 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} _record Record representing this object type. + * @param {Partial< ObjectData >} _changes Updates to make. + * @return {Promise< Record< string, any > >} Entity meta. + */ + public async createEntityMeta( + _syncConfig: SyncConfig, + _record: ObjectData, + _changes: Partial< 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. + * + * @param {SyncConfig} _syncConfig Sync configuration for the object type. + * @param {ObjectData} _record Record representing this object type. + * @param {number} _expectedVersion Expected version of persisted CRDT document. + * @return {Promise< CRDTDoc | null >} The persisted CRDT document, or null if none exists. + */ + protected async getPersistedCRDTDoc( + _syncConfig: SyncConfig, + _record: ObjectData, + _expectedVersion: number + ): 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 this.undoManager; + } + + /** + * Update CRDT document with changes from the local store. + * + * @param {ObjectType} objectType Object type to load. + * @param {ObjectData} record Record to load. + * @param {Partial< ObjectData >} changes Updates to make. + * @param {string} origin The source of change. + */ + public update( + objectType: ObjectType, + record: ObjectData, + changes: Partial< ObjectData >, + origin: string + ): void { + const syncConfig = this.configs.get( objectType ); + const objectId = syncConfig?.getObjectId( record ); + + if ( ! syncConfig || ! objectId ) { + return; + } + + const ydoc = this.getEntityState( objectType, objectId )?.ydoc; + + ydoc?.transact( () => { + syncConfig.applyChangesToCRDTDoc( ydoc, changes, origin ); + }, origin ); + } + + /** + * Stop updating a document and discard it. + * + * @param {ObjectType} objectType Object type to discard. + * @param {ObjectID} objectId Object ID to discard. + */ + public discard( objectType: ObjectType, objectId: ObjectID ): void { + this.getEntityState( objectType, objectId )?.destroy(); + } +} diff --git a/packages/sync/src/types.ts b/packages/sync/src/types.ts index 03439ecf280319..41643a6d36d94a 100644 --- a/packages/sync/src/types.ts +++ b/packages/sync/src/types.ts @@ -1,27 +1,45 @@ +/** + * External dependencies + */ +import type * as Y from 'yjs'; +import type { Awareness } from 'y-protocols/awareness'; + +export type * as Y from 'yjs'; +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 UndoManager = Y.UndoManager; -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, but defining some +// optional properties cuts down on the type narrowing. +export interface ObjectData extends Record< string, unknown > { + meta?: Record< string, unknown >; + status?: string; +} + +export interface ConnectDocResult { + awareness?: Awareness; + destroy: () => void; +} export type ConnectDoc = ( id: ObjectID, type: ObjectType, - doc: CRDTDoc -) => Promise< () => void >; + ydoc: Y.Doc +) => Promise< ConnectDocResult >; -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 type SyncConfig = { + applyChangesToCRDTDoc: ( + ydoc: Y.Doc, + data: Partial< ObjectData >, + origin: string + ) => void; + fromCRDTDoc: ( ydoc: Y.Doc ) => ObjectData; + getInitialObjectData: ( record: ObjectData ) => ObjectData; + getObjectId: ( data: ObjectData ) => ObjectID; + objectType: ObjectType; + supportsAwareness?: boolean; + supportsUndo?: boolean; }; diff --git a/packages/sync/src/undo-manager.ts b/packages/sync/src/undo-manager.ts new file mode 100644 index 00000000000000..63c24234094272 --- /dev/null +++ b/packages/sync/src/undo-manager.ts @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import * as Y from 'yjs'; + +/** + * WordPress dependencies + */ +import type { + HistoryRecord, + UndoManager as WPUndoManager, +} from '@wordpress/undo-manager'; + +/** + * Internal dependencies + */ +import type { CRDTDoc, ObjectData } from './types'; + +/** + * Wrapper class that provides the WordPress UndoManager interface while using Y.UndoManager internally. + * This allows seamless integration between Yjs collaborative editing and WordPress undo/redo functionality. + */ +export class UndoManager implements WPUndoManager< ObjectData > { + private undoManager: Y.UndoManager; + + public constructor( ydoc: CRDTDoc ) { + this.undoManager = new Y.UndoManager( ydoc.getMap( 'document' ), { + // Ensure we undo and redo one character at a time. + captureTimeout: 0, + // Ensure that we only scope the undo/redo to the current client, and Gutenberg origins. + // ToDo: Keep an eye on this, as it needs to be battle tested. + trackedOrigins: new Set( [ 'gutenberg', ydoc.clientID ] ), + // This ensures that are able to improve the client specific undo/redo experience. + // This reduces the bugs we see, but it doesn't eliminate them entirely. + ignoreRemoteMapChanges: true, + } ); + } + + /** + * 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. + } + + /** + * Undo the last recorded changes. + * + * @return The undone record or undefined if nothing to undo. + */ + public undo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasUndo() ) { + return; + } + + // Perform the undo operation + this.undoManager.undo(); + + // @TODO See if the undo operation can return a record from Yjs. + return []; + } + + /** + * Redo the last undone changes. + * + * @return The redone record or undefined if nothing to redo. + */ + public redo(): HistoryRecord< ObjectData > | undefined { + if ( ! this.hasRedo() ) { + return; + } + + // Perform the redo operation + this.undoManager.redo(); + + // @TODO See if the redo operation can return a record from Yjs. + 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/tsconfig.json b/packages/sync/tsconfig.json index f0a5cb0530d297..53e6a2b663d310 100644 --- a/packages/sync/tsconfig.json +++ b/packages/sync/tsconfig.json @@ -4,5 +4,5 @@ "compilerOptions": { "types": [ "node" ] }, - "references": [ { "path": "../url" } ] + "references": [ { "path": "../hooks" }, { "path": "../url" } ] } 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',