diff --git a/package-lock.json b/package-lock.json index 8c12c77c0624eb..fc72add1f51df5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24988,9 +24988,10 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-diff": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", - "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" }, "node_modules/fast-fifo": { "version": "1.3.0", @@ -32334,8 +32335,7 @@ "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", - "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", - "dev": true + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, "node_modules/lodash.debounce": { "version": "4.0.8", @@ -32363,6 +32363,13 @@ "resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz", "integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.ismatch": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", @@ -39569,6 +39576,20 @@ "node": ">=8" } }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -50448,6 +50469,7 @@ "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "memize": "^2.1.0", + "quill-delta": "5.1.0", "uuid": "^9.0.1" }, "engines": { diff --git a/packages/core-data/package.json b/packages/core-data/package.json index 6057777c80d5c6..e0b592dd1f340c 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -55,6 +55,7 @@ "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "memize": "^2.1.0", + "quill-delta": "5.1.0", "uuid": "^9.0.1" }, "peerDependencies": { diff --git a/packages/core-data/src/types.ts b/packages/core-data/src/types.ts index b1664d8ab8dbf7..dac976505a8f2d 100644 --- a/packages/core-data/src/types.ts +++ b/packages/core-data/src/types.ts @@ -1,3 +1,15 @@ export interface AnyFunction { ( ...args: any[] ): any; } + +// Avoid a circular dependency with @wordpress/editor +export interface WPBlockSelection { + clientId: string; + attributeKey: string; + offset: number; +} + +export interface WPSelection { + selectionEnd: WPBlockSelection; + selectionStart: WPBlockSelection; +} diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 5683ab38ebde09..5551d0454dd870 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as math from 'lib0/math'; import * as fun from 'lib0/function'; +import Delta from 'quill-delta'; /** * WordPress dependencies @@ -14,6 +15,11 @@ 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'; +/** + * Internal dependencies + */ +import type { WPBlockSelection } from '../types'; + interface BlockAttributes { [ key: string ]: unknown; } @@ -138,8 +144,8 @@ function createNewYAttributeValue( ): Y.Text | unknown { const isRichText = isRichTextAttribute( blockName, attributeName ); - if ( isRichText && 'string' === typeof attributeValue ) { - return new Y.Text( attributeValue ); + if ( isRichText ) { + return new Y.Text( attributeValue?.toString() ?? '' ); } return attributeValue; @@ -184,11 +190,13 @@ function createNewYBlock( block: Block ): YBlock { * * @param yblocks The blocks in the local Y.Doc. * @param incomingBlocks Gutenberg blocks being synced. + * @param lastSelection * @param _origin The origin of the sync, either 'syncProvider' or 'gutenberg'. */ export function mergeCrdtBlocks( yblocks: Y.Array< YBlock >, // yblocks represent the blocks in the local Y.Doc incomingBlocks: Block[], // incomingBlocks represent JSON blocks being synced, either from a peer or from the local editor + lastSelection: WPBlockSelection | null, // Last cursor position, used for hinting the diff algorithm _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // Ensure we are working with serializable block data. @@ -287,14 +295,36 @@ export function mergeCrdtBlocks( return; } - currentAttributes.set( - attributeName, - createNewYAttributeValue( - block.name, - attributeName, - attributeValue - ) + const isRichText = isRichTextAttribute( + block.name, + attributeName ); + + if ( + isRichText && + 'string' === typeof attributeValue + ) { + // Rich text values are stored as persistent Y.Text instances. + // Update the value with a delta in place. + const blockYText = currentAttributes.get( + attributeName + ) as Y.Text; + + mergeRichTextUpdate( + blockYText, + attributeValue, + lastSelection + ); + } else { + currentAttributes.set( + attributeName, + createNewYAttributeValue( + block.name, + attributeName, + attributeValue + ) + ); + } } ); @@ -313,7 +343,12 @@ export function mergeCrdtBlocks( case 'innerBlocks': { // Recursively merge innerBlocks const yInnerBlocks = yblock.get( key ) as Y.Array< YBlock >; - mergeCrdtBlocks( yInnerBlocks, value ?? [], _origin ); + mergeCrdtBlocks( + yInnerBlocks, + value ?? [], + lastSelection, + _origin + ); break; } @@ -423,3 +458,45 @@ function isRichTextAttribute( cachedRichTextAttributes.get( blockName )?.has( attributeName ) ?? false ); } + +let localDoc: Y.Doc | null = null; + +/** + * Given a Y.Text object and an updated string value, diff the new value and + * apply the delta to the Y.Text. + * + * @param blockYText The Y.Text to update. + * @param updatedValue The updated value. + * @param lastSelection The last cursor position before this update, used to hint the diff algorithm. + */ +function mergeRichTextUpdate( + blockYText: Y.Text, + updatedValue: string, + lastSelection: WPBlockSelection | null +): void { + const doc = blockYText.doc; + + if ( ! doc ) { + throw new Error( 'mergeCrdtBlocks: Y.Text is not attached to a Y.Doc' ); + } + + if ( ! localDoc ) { + // Y.Text must be attached to a Y.Doc to be able to do operations on it. + // Create a temporary Y.Text attached to a local Y.Doc for delta computation. + localDoc = new Y.Doc(); + } + + const localYText = localDoc.getText( 'temporary-text' ); + localYText.delete( 0, localYText.length ); + localYText.insert( 0, updatedValue ); + + const currentValueAsDelta = new Delta( blockYText.toDelta() ); + const updatedValueAsDelta = new Delta( localYText.toDelta() ); + + const deltaDiff = currentValueAsDelta.diff( + updatedValueAsDelta, + lastSelection?.offset + ); + + blockYText.applyDelta( deltaDiff.ops ); +} diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index 417e33d7c1c376..fc7b35b2082251 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -17,8 +17,14 @@ import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync'; import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; import { type Post } from '../entity-types/post'; import { type Type } from '../entity-types'; +import type { WPBlockSelection, WPSelection } from '../types'; -type PostChanges = Partial< Post > & { blocks?: Block[] }; +type PostChanges = Partial< Post > & { + blocks?: Block[]; + selection?: WPSelection; +}; + +let lastSelection: WPBlockSelection | null = null; /** * Given a set of local changes to a post record, apply those changes to the @@ -72,7 +78,12 @@ export function applyPostChangesToCRDTDoc( // Merge blocks does not need `setValue` because it is operating on a // Yjs type that is already in the Y.Doc. - mergeCrdtBlocks( currentBlocks, newBlocks, origin ); + mergeCrdtBlocks( + currentBlocks, + newBlocks, + lastSelection, + origin + ); break; } @@ -166,6 +177,11 @@ export function applyPostChangesToCRDTDoc( } } } ); + + // Update the lastSelection for CRDT use + if ( 'selection' in changes ) { + lastSelection = changes.selection?.selectionStart ?? null; + } } /**