diff --git a/.eslintignore b/.eslintignore index caadb256c28dd6..8ed7632075fde7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,5 +5,6 @@ build-types node_modules packages/block-serialization-spec-parser/parser.js packages/react-native-editor/bundle +packages/sync/src/quill-delta vendor !.*.js diff --git a/.prettierignore b/.prettierignore index d50079e4ef71ef..46b6585088cd03 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,5 +5,6 @@ build-types packages/block-serialization-spec-parser/parser.js packages/edit-site/lib packages/react-native-editor/bundle +packages/sync/src/quill-delta packages/url/src/test/fixtures vendor diff --git a/package-lock.json b/package-lock.json index 4bf585c4523ec7..de83cb41671219 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14148,9 +14148,26 @@ "version": "4.14.199", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.199.tgz", "integrity": "sha512-Vrjz5N5Ia4SEzWWgIVwnHNEnb1UE1XMkvY5DGXrAeOGE9imk0hgTHh5GyDjLDJi9OTCn9oo9dXH1uToK1VRfrg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz", + "integrity": "sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.isequal": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz", + "integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mdx": { "version": "2.0.13", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", @@ -39587,20 +39604,6 @@ "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", @@ -50480,7 +50483,6 @@ "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "memize": "^2.1.0", - "quill-delta": "5.1.0", "uuid": "^9.0.1" }, "engines": { @@ -52412,10 +52414,15 @@ "version": "1.31.0", "license": "GPL-2.0-or-later", "dependencies": { + "@types/lodash.clonedeep": "^4.5.6", + "@types/lodash.isequal": "^4.5.5", "@types/simple-peer": "^9.11.5", "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", + "diff": "^8.0.2", "lib0": "^0.2.99", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0", "simple-peer": "^9.11.0", "y-indexeddb": "^9.0.12", "y-protocols": "^1.0.6", @@ -52426,6 +52433,15 @@ "npm": ">=8.19.2" } }, + "packages/sync/node_modules/diff": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "packages/sync/node_modules/y-indexeddb": { "version": "9.0.12", "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", diff --git a/packages/core-data/package.json b/packages/core-data/package.json index e0b592dd1f340c..6057777c80d5c6 100644 --- a/packages/core-data/package.json +++ b/packages/core-data/package.json @@ -55,7 +55,6 @@ "fast-deep-equal": "^3.1.3", "lib0": "^0.2.99", "memize": "^2.1.0", - "quill-delta": "5.1.0", "uuid": "^9.0.1" }, "peerDependencies": { diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index 5551d0454dd870..ba28bd521d7e0f 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -4,22 +4,16 @@ import { v4 as uuidv4 } from 'uuid'; import * as math from 'lib0/math'; import * as fun from 'lib0/function'; -import Delta from 'quill-delta'; /** * WordPress dependencies */ import { RichTextData } from '@wordpress/rich-text'; -import { Y } from '@wordpress/sync'; +import { Y, Delta } from '@wordpress/sync'; // @ts-expect-error - This is a TypeScript file, and @wordpress/blocks doesn't have a tsconfig.json? import { getBlockTypes } from '@wordpress/blocks'; -/** - * Internal dependencies - */ -import type { WPBlockSelection } from '../types'; - interface BlockAttributes { [ key: string ]: unknown; } @@ -190,13 +184,13 @@ function createNewYBlock( block: Block ): YBlock { * * @param yblocks The blocks in the local Y.Doc. * @param incomingBlocks Gutenberg blocks being synced. - * @param lastSelection + * @param cursorPosition The position of the cursor after the change occurs. * @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 + cursorPosition: number | null, _origin: string // eslint-disable-line @typescript-eslint/no-unused-vars ): void { // Ensure we are working with serializable block data. @@ -313,7 +307,7 @@ export function mergeCrdtBlocks( mergeRichTextUpdate( blockYText, attributeValue, - lastSelection + cursorPosition ); } else { currentAttributes.set( @@ -346,7 +340,7 @@ export function mergeCrdtBlocks( mergeCrdtBlocks( yInnerBlocks, value ?? [], - lastSelection, + cursorPosition, _origin ); break; @@ -465,14 +459,14 @@ let localDoc: Y.Doc | null = null; * Given a Y.Text object and an updated string value, diff the new value and * apply the delta to the Y.Text. * - * @param blockYText The Y.Text to update. - * @param updatedValue The updated value. - * @param lastSelection The last cursor position before this update, used to hint the diff algorithm. + * @param blockYText The Y.Text to update. + * @param updatedValue The updated value. + * @param cursorPosition The position of the cursor after the change occurs. */ function mergeRichTextUpdate( blockYText: Y.Text, updatedValue: string, - lastSelection: WPBlockSelection | null + cursorPosition: number | null ): void { const doc = blockYText.doc; @@ -492,10 +486,9 @@ function mergeRichTextUpdate( const currentValueAsDelta = new Delta( blockYText.toDelta() ); const updatedValueAsDelta = new Delta( localYText.toDelta() ); - - const deltaDiff = currentValueAsDelta.diff( + const deltaDiff = currentValueAsDelta.diffWithCursor( updatedValueAsDelta, - lastSelection?.offset + cursorPosition ); blockYText.applyDelta( deltaDiff.ops ); diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index fc7b35b2082251..827fec37a5276d 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -17,15 +17,13 @@ import { type CRDTDoc, CRDT_RECORD_MAP_KEY, Y } from '@wordpress/sync'; import { mergeCrdtBlocks, type Block, type YBlock } from './crdt-blocks'; import { type Post } from '../entity-types/post'; import { type Type } from '../entity-types'; -import type { WPBlockSelection, WPSelection } from '../types'; +import type { WPSelection } from '../types'; type PostChanges = Partial< Post > & { blocks?: Block[]; selection?: WPSelection; }; -let lastSelection: WPBlockSelection | null = null; - /** * Given a set of local changes to a post record, apply those changes to the * local Y.Doc. @@ -76,12 +74,17 @@ export function applyPostChangesToCRDTDoc( // Block[] from local changes. const newBlocks = ( newValue as PostChanges[ 'blocks' ] ) ?? []; + // Block changes from typing are bundled with a 'selection' update. + // Pass the resulting cursor position to the mergeCrdtBlocks function. + const cursorPosition = + changes.selection?.selectionStart?.offset ?? null; + // Merge blocks does not need `setValue` because it is operating on a // Yjs type that is already in the Y.Doc. mergeCrdtBlocks( currentBlocks, newBlocks, - lastSelection, + cursorPosition, origin ); break; @@ -177,11 +180,6 @@ export function applyPostChangesToCRDTDoc( } } } ); - - // Update the lastSelection for CRDT use - if ( 'selection' in changes ) { - lastSelection = changes.selection?.selectionStart ?? null; - } } /** diff --git a/packages/core-data/src/utils/test/crdt-blocks.ts b/packages/core-data/src/utils/test/crdt-blocks.ts index b413e72a854144..8afa5028764e9e 100644 --- a/packages/core-data/src/utils/test/crdt-blocks.ts +++ b/packages/core-data/src/utils/test/crdt-blocks.ts @@ -40,6 +40,12 @@ jest.mock( '@wordpress/rich-text', () => { class MockRichTextData { private text: string = ''; + constructor( text?: string ) { + if ( text ) { + this.text = text; + } + } + toString() { return this.text; } @@ -106,14 +112,33 @@ jest.mock( '@wordpress/blocks', () => ( { * Mock @wordpress/sync - Yjs implementation */ jest.mock( '@wordpress/sync', () => { + class MockYDoc { + private texts: Map< string, any > = new Map(); + + getText( name: string ): any { + if ( ! this.texts.has( name ) ) { + this.texts.set( name, new MockYText( '', this ) ); + } + return this.texts.get( name ); + } + } + class MockYMap { private data: Map< string, any > = new Map(); + public doc: MockYDoc | null = null; constructor( entries?: Array< [ string, any ] > ) { + // Create a doc for this map + this.doc = new MockYDoc(); + if ( entries ) { - entries.forEach( ( [ key, value ] ) => - this.data.set( key, value ) - ); + entries.forEach( ( [ key, value ] ) => { + // If the value is a Y.Text, attach the doc to it + if ( value instanceof MockYText && ! value.doc ) { + value.doc = this.doc; + } + this.data.set( key, value ); + } ); } } @@ -122,6 +147,10 @@ jest.mock( '@wordpress/sync', () => { } set( key: string, value: any ): void { + // If the value is a Y.Text, attach the doc to it + if ( value instanceof MockYText && ! value.doc ) { + value.doc = this.doc; + } this.data.set( key, value ); } @@ -189,9 +218,15 @@ jest.mock( '@wordpress/sync', () => { class MockYText { private text: string = ''; + public doc: MockYDoc | null = null; - constructor( text: string = '' ) { + constructor( text: string = '', doc: MockYDoc | null = null ) { this.text = text; + this.doc = doc; + } + + get length(): number { + return this.text.length; } toString(): string { @@ -208,17 +243,89 @@ jest.mock( '@wordpress/sync', () => { this.text.slice( 0, index ) + this.text.slice( index + length ); } + toDelta(): Array< { + insert?: string; + delete?: number; + retain?: number; + } > { + return [ { insert: this.text } ]; + } + + applyDelta( + ops: Array< { insert?: string; delete?: number; retain?: number } > + ): void { + let index = 0; + for ( const op of ops ) { + if ( op.retain ) { + index += op.retain; + } else if ( op.insert ) { + this.insert( index, op.insert ); + index += op.insert.length; + } else if ( op.delete ) { + this.delete( index, op.delete ); + } + } + } + toJSON(): string { return this.text; } } + class MockDelta { + public ops: Array< { + insert?: string; + delete?: number; + retain?: number; + } > = []; + + constructor( + ops?: Array< { insert?: string; delete?: number; retain?: number } > + ) { + this.ops = ops || []; + } + + diff( other: MockDelta ): MockDelta { + // Simple diff implementation for testing + const currentText = this.ops + .map( ( op ) => op.insert || '' ) + .join( '' ); + const otherText = other.ops + .map( ( op ) => op.insert || '' ) + .join( '' ); + + if ( currentText === otherText ) { + return new MockDelta( [] ); + } + + // Simple replace operation + const deltaOps = []; + if ( currentText.length > 0 ) { + deltaOps.push( { delete: currentText.length } ); + } + if ( otherText.length > 0 ) { + deltaOps.push( { insert: otherText } ); + } + + return new MockDelta( deltaOps ); + } + + diffWithCursor( + other: MockDelta, + _cursorAfterChange: number | null // eslint-disable-line @typescript-eslint/no-unused-vars + ): MockDelta { + return this.diff( other ); + } + } + return { Y: { + Doc: MockYDoc, Map: MockYMap, Array: MockYArray, Text: MockYText, }, + Delta: MockDelta, }; } ); @@ -253,7 +360,12 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, incomingBlocks, 'gutenberg' ); + mergeCrdtBlocks( + yblocks as any, + incomingBlocks, + null, + 'gutenberg' + ); expect( yblocks.length ).toBe( 1 ); const block = yblocks.get( 0 ) as YBlock; @@ -275,7 +387,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, initialBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, initialBlocks, null, 'gutenberg' ); const updatedBlocks: Block[] = [ { @@ -286,7 +398,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, updatedBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, updatedBlocks, null, 'gutenberg' ); expect( yblocks.length ).toBe( 1 ); const block = yblocks.get( 0 ) as YBlock; @@ -313,7 +425,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, initialBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, initialBlocks, null, 'gutenberg' ); expect( yblocks.length ).toBe( 2 ); const updatedBlocks: Block[] = [ @@ -325,7 +437,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, updatedBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, updatedBlocks, null, 'gutenberg' ); expect( yblocks.length ).toBe( 1 ); const block = yblocks.get( 0 ) as YBlock; @@ -351,7 +463,12 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, blocksWithInner, 'gutenberg' ); + mergeCrdtBlocks( + yblocks as any, + blocksWithInner, + null, + 'gutenberg' + ); expect( yblocks.length ).toBe( 1 ); const block = yblocks.get( 0 ) as YBlock; @@ -380,7 +497,12 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, galleryWithBlobs, 'gutenberg' ); + mergeCrdtBlocks( + yblocks as any, + galleryWithBlobs, + null, + 'gutenberg' + ); // Gallery block should not be synced because it has blob attributes expect( yblocks.length ).toBe( 0 ); @@ -404,7 +526,12 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, galleryWithoutBlobs, 'gutenberg' ); + mergeCrdtBlocks( + yblocks as any, + galleryWithoutBlobs, + null, + 'gutenberg' + ); expect( yblocks.length ).toBe( 1 ); const block = yblocks.get( 0 ) as YBlock; @@ -428,7 +555,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, initialBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, initialBlocks, null, 'gutenberg' ); // Reorder blocks const reorderedBlocks: Block[] = [ @@ -446,7 +573,12 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, reorderedBlocks, 'gutenberg' ); + mergeCrdtBlocks( + yblocks as any, + reorderedBlocks, + null, + 'gutenberg' + ); expect( yblocks.length ).toBe( 2 ); const block0 = yblocks.get( 0 ) as YBlock; @@ -472,7 +604,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, blocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, blocks, null, 'gutenberg' ); const block = yblocks.get( 0 ) as YBlock; const contentAttr = ( @@ -501,6 +633,7 @@ describe( 'crdt-blocks', () => { mergeCrdtBlocks( yblocks as any, blocksWithDuplicateIds, + null, 'gutenberg' ); @@ -525,7 +658,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, initialBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, initialBlocks, null, 'gutenberg' ); const updatedBlocks: Block[] = [ { @@ -537,7 +670,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, updatedBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, updatedBlocks, null, 'gutenberg' ); const block = yblocks.get( 0 ) as YBlock; const attributes = block.get( 'attributes' ) as YBlockAttributes; @@ -565,7 +698,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, initialBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, initialBlocks, null, 'gutenberg' ); // Update only the middle block const updatedBlocks: Block[] = [ @@ -586,7 +719,7 @@ describe( 'crdt-blocks', () => { }, ]; - mergeCrdtBlocks( yblocks as any, updatedBlocks, 'gutenberg' ); + mergeCrdtBlocks( yblocks as any, updatedBlocks, null, 'gutenberg' ); expect( yblocks.length ).toBe( 3 ); const block = yblocks.get( 1 ) as YBlock; diff --git a/packages/sync/README.md b/packages/sync/README.md index 5e19247f035ae2..6df8c2c041c6aa 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -14,6 +14,10 @@ npm install @wordpress/sync --save +### AttributeMap + +Undocumented declaration. + ### connectIndexDb Connect function to the IndexedDB persistence provider. @@ -106,6 +110,10 @@ _Returns_ - `ConnectDoc`: Promise that resolves when the connection is established. +### Delta + +Undocumented declaration. + ### getWebRTCSyncProvider Returns a WebRTC sync provider. This is the curent default sync provider. @@ -130,6 +138,14 @@ _Type_ - `string` +### Op + +Undocumented declaration. + +### OpIterator + +Undocumented declaration. + ### SyncProvider The SyncProvider manages access to CRDT documents for multiple entities, including their lifecycle, connections, and syncing changes between the CRDT document and the local store. diff --git a/packages/sync/package.json b/packages/sync/package.json index 3cdae4d7908346..fb1d466890ab16 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -29,10 +29,15 @@ "types": "build-types", "sideEffects": false, "dependencies": { + "@types/lodash.clonedeep": "^4.5.6", + "@types/lodash.isequal": "^4.5.5", "@types/simple-peer": "^9.11.5", "@wordpress/hooks": "file:../hooks", "@wordpress/url": "file:../url", + "diff": "^8.0.2", "lib0": "^0.2.99", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0", "simple-peer": "^9.11.0", "y-indexeddb": "^9.0.12", "y-protocols": "^1.0.6", diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 3948d915746a51..93ad622e7c941a 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -18,6 +18,12 @@ export { connectIndexDb } from './connect-indexdb'; export { createWebRTCConnection } from './create-webrtc-connection'; export { SyncProvider } from './provider'; export * from './types'; +export { + default as Delta, + Op, + OpIterator, + AttributeMap, +} from './quill-delta/Delta'; declare global { interface Window { diff --git a/packages/sync/src/quill-delta/AttributeMap.ts b/packages/sync/src/quill-delta/AttributeMap.ts new file mode 100644 index 00000000000000..77281a0ec92aab --- /dev/null +++ b/packages/sync/src/quill-delta/AttributeMap.ts @@ -0,0 +1,101 @@ +import cloneDeep from 'lodash.clonedeep'; +import isEqual from 'lodash.isequal'; + +interface AttributeMap { + [key: string]: unknown; +} + +namespace AttributeMap { + export function compose( + a: AttributeMap = {}, + b: AttributeMap = {}, + keepNull = false, + ): AttributeMap | undefined { + if (typeof a !== 'object') { + a = {}; + } + if (typeof b !== 'object') { + b = {}; + } + let attributes = cloneDeep(b); + if (!keepNull) { + attributes = Object.keys(attributes).reduce((copy, key) => { + if (attributes[key] != null) { + copy[key] = attributes[key]; + } + return copy; + }, {}); + } + for (const key in a) { + if (a[key] !== undefined && b[key] === undefined) { + attributes[key] = a[key]; + } + } + return Object.keys(attributes).length > 0 ? attributes : undefined; + } + + export function diff( + a: AttributeMap = {}, + b: AttributeMap = {}, + ): AttributeMap | undefined { + if (typeof a !== 'object') { + a = {}; + } + if (typeof b !== 'object') { + b = {}; + } + const attributes = Object.keys(a) + .concat(Object.keys(b)) + .reduce((attrs, key) => { + if (!isEqual(a[key], b[key])) { + attrs[key] = b[key] === undefined ? null : b[key]; + } + return attrs; + }, {}); + return Object.keys(attributes).length > 0 ? attributes : undefined; + } + + export function invert( + attr: AttributeMap = {}, + base: AttributeMap = {}, + ): AttributeMap { + attr = attr || {}; + const baseInverted = Object.keys(base).reduce((memo, key) => { + if (base[key] !== attr[key] && attr[key] !== undefined) { + memo[key] = base[key]; + } + return memo; + }, {}); + return Object.keys(attr).reduce((memo, key) => { + if (attr[key] !== base[key] && base[key] === undefined) { + memo[key] = null; + } + return memo; + }, baseInverted); + } + + export function transform( + a: AttributeMap | undefined, + b: AttributeMap | undefined, + priority = false, + ): AttributeMap | undefined { + if (typeof a !== 'object') { + return b; + } + if (typeof b !== 'object') { + return undefined; + } + if (!priority) { + return b; // b simply overwrites us without priority + } + const attributes = Object.keys(b).reduce((attrs, key) => { + if (a[key] === undefined) { + attrs[key] = b[key]; // null is a valid value + } + return attrs; + }, {}); + return Object.keys(attributes).length > 0 ? attributes : undefined; + } +} + +export default AttributeMap; diff --git a/packages/sync/src/quill-delta/Delta.ts b/packages/sync/src/quill-delta/Delta.ts new file mode 100644 index 00000000000000..d2cac93daed545 --- /dev/null +++ b/packages/sync/src/quill-delta/Delta.ts @@ -0,0 +1,847 @@ +// File copied https://github.com/slab/delta/blob/main/src/Delta.ts, with fast-diff swapped out for 'diff', +// lodash.clonedeep and lodash.isequal swapped out for esm imports, and cursorPos dropped from diff. + +import { Change, diffChars } from 'diff'; +import cloneDeep from 'lodash.clonedeep'; +import isEqual from 'lodash.isequal'; +import AttributeMap from './AttributeMap'; +import Op from './Op'; +import OpIterator from './OpIterator'; + + +const NULL_CHARACTER = String.fromCharCode(0); // Placeholder char for embed in diff() + +interface EmbedHandler { + compose(a: T, b: T, keepNull: boolean): T; + invert(a: T, b: T): T; + transform(a: T, b: T, priority: boolean): T; +} + +const getEmbedTypeAndData = ( + a: Op['insert'] | Op['retain'], + b: Op['insert'], +): [string, unknown, unknown] => { + if (typeof a !== 'object' || a === null) { + throw new Error(`cannot retain a ${typeof a}`); + } + if (typeof b !== 'object' || b === null) { + throw new Error(`cannot retain a ${typeof b}`); + } + const embedType = Object.keys(a)[0]; + if (!embedType || embedType !== Object.keys(b)[0]) { + throw new Error( + `embed types not matched: ${embedType} != ${Object.keys(b)[0]}`, + ); + } + return [embedType, a[embedType], b[embedType]]; +}; + +class Delta { + static Op = Op; + static OpIterator = OpIterator; + static AttributeMap = AttributeMap; + private static handlers: { [embedType: string]: EmbedHandler } = {}; + + static registerEmbed(embedType: string, handler: EmbedHandler): void { + this.handlers[embedType] = handler; + } + + static unregisterEmbed(embedType: string): void { + delete this.handlers[embedType]; + } + + private static getHandler(embedType: string): EmbedHandler { + const handler = this.handlers[embedType]; + if (!handler) { + throw new Error(`no handlers for embed type "${embedType}"`); + } + return handler; + } + + ops: Op[]; + constructor(ops?: Op[] | { ops: Op[] }) { + // Assume we are given a well formed ops + if (Array.isArray(ops)) { + this.ops = ops; + } else if (ops != null && Array.isArray(ops.ops)) { + this.ops = ops.ops; + } else { + this.ops = []; + } + } + + insert( + arg: string | Record, + attributes?: AttributeMap | null, + ): this { + const newOp: Op = {}; + if (typeof arg === 'string' && arg.length === 0) { + return this; + } + newOp.insert = arg; + if ( + attributes != null && + typeof attributes === 'object' && + Object.keys(attributes).length > 0 + ) { + newOp.attributes = attributes; + } + return this.push(newOp); + } + + delete(length: number): this { + if (length <= 0) { + return this; + } + return this.push({ delete: length }); + } + + retain( + length: number | Record, + attributes?: AttributeMap | null, + ): this { + if (typeof length === 'number' && length <= 0) { + return this; + } + const newOp: Op = { retain: length }; + if ( + attributes != null && + typeof attributes === 'object' && + Object.keys(attributes).length > 0 + ) { + newOp.attributes = attributes; + } + return this.push(newOp); + } + + push(newOp: Op): this { + let index = this.ops.length; + let lastOp = this.ops[index - 1]; + newOp = cloneDeep(newOp); + if (typeof lastOp === 'object') { + if ( + typeof newOp.delete === 'number' && + typeof lastOp.delete === 'number' + ) { + this.ops[index - 1] = { delete: lastOp.delete + newOp.delete }; + return this; + } + // Since it does not matter if we insert before or after deleting at the same index, + // always prefer to insert first + if (typeof lastOp.delete === 'number' && newOp.insert != null) { + index -= 1; + lastOp = this.ops[index - 1]; + if (typeof lastOp !== 'object') { + this.ops.unshift(newOp); + return this; + } + } + if (isEqual(newOp.attributes, lastOp.attributes)) { + if ( + typeof newOp.insert === 'string' && + typeof lastOp.insert === 'string' + ) { + this.ops[index - 1] = { insert: lastOp.insert + newOp.insert }; + if (typeof newOp.attributes === 'object') { + this.ops[index - 1].attributes = newOp.attributes; + } + return this; + } else if ( + typeof newOp.retain === 'number' && + typeof lastOp.retain === 'number' + ) { + this.ops[index - 1] = { retain: lastOp.retain + newOp.retain }; + if (typeof newOp.attributes === 'object') { + this.ops[index - 1].attributes = newOp.attributes; + } + return this; + } + } + } + if (index === this.ops.length) { + this.ops.push(newOp); + } else { + this.ops.splice(index, 0, newOp); + } + return this; + } + + chop(): this { + const lastOp = this.ops[this.ops.length - 1]; + if (lastOp && typeof lastOp.retain === 'number' && !lastOp.attributes) { + this.ops.pop(); + } + return this; + } + + filter(predicate: (op: Op, index: number) => boolean): Op[] { + return this.ops.filter(predicate); + } + + forEach(predicate: (op: Op, index: number) => void): void { + this.ops.forEach(predicate); + } + + map(predicate: (op: Op, index: number) => T): T[] { + return this.ops.map(predicate); + } + + partition(predicate: (op: Op) => boolean): [Op[], Op[]] { + const passed: Op[] = []; + const failed: Op[] = []; + this.forEach((op) => { + const target = predicate(op) ? passed : failed; + target.push(op); + }); + return [passed, failed]; + } + + reduce( + predicate: (accum: T, curr: Op, index: number) => T, + initialValue: T, + ): T { + return this.ops.reduce(predicate, initialValue); + } + + changeLength(): number { + return this.reduce((length, elem) => { + if (elem.insert) { + return length + Op.length(elem); + } else if (elem.delete) { + return length - elem.delete; + } + return length; + }, 0); + } + + length(): number { + return this.reduce((length, elem) => { + return length + Op.length(elem); + }, 0); + } + + slice(start = 0, end = Infinity): Delta { + const ops = []; + const iter = new OpIterator(this.ops); + let index = 0; + while (index < end && iter.hasNext()) { + let nextOp; + if (index < start) { + nextOp = iter.next(start - index); + } else { + nextOp = iter.next(end - index); + ops.push(nextOp); + } + index += Op.length(nextOp); + } + return new Delta(ops); + } + + compose(other: Delta): Delta { + const thisIter = new OpIterator(this.ops); + const otherIter = new OpIterator(other.ops); + const ops = []; + const firstOther = otherIter.peek(); + if ( + firstOther != null && + typeof firstOther.retain === 'number' && + firstOther.attributes == null + ) { + let firstLeft = firstOther.retain; + while ( + thisIter.peekType() === 'insert' && + thisIter.peekLength() <= firstLeft + ) { + firstLeft -= thisIter.peekLength(); + ops.push(thisIter.next()); + } + if (firstOther.retain - firstLeft > 0) { + otherIter.next(firstOther.retain - firstLeft); + } + } + const delta = new Delta(ops); + while (thisIter.hasNext() || otherIter.hasNext()) { + if (otherIter.peekType() === 'insert') { + delta.push(otherIter.next()); + } else if (thisIter.peekType() === 'delete') { + delta.push(thisIter.next()); + } else { + const length = Math.min(thisIter.peekLength(), otherIter.peekLength()); + const thisOp = thisIter.next(length); + const otherOp = otherIter.next(length); + if (otherOp.retain) { + const newOp: Op = {}; + if (typeof thisOp.retain === 'number') { + newOp.retain = + typeof otherOp.retain === 'number' ? length : otherOp.retain; + } else { + if (typeof otherOp.retain === 'number') { + if (thisOp.retain == null) { + newOp.insert = thisOp.insert; + } else { + newOp.retain = thisOp.retain; + } + } else { + const action = thisOp.retain == null ? 'insert' : 'retain'; + const [embedType, thisData, otherData] = getEmbedTypeAndData( + thisOp[action], + otherOp.retain, + ); + const handler = Delta.getHandler(embedType); + newOp[action] = { + [embedType]: handler.compose( + thisData, + otherData, + action === 'retain', + ), + }; + } + } + // Preserve null when composing with a retain, otherwise remove it for inserts + const attributes = AttributeMap.compose( + thisOp.attributes, + otherOp.attributes, + typeof thisOp.retain === 'number', + ); + if (attributes) { + newOp.attributes = attributes; + } + delta.push(newOp); + + // Optimization if rest of other is just retain + if ( + !otherIter.hasNext() && + isEqual(delta.ops[delta.ops.length - 1], newOp) + ) { + const rest = new Delta(thisIter.rest()); + return delta.concat(rest).chop(); + } + + // Other op should be delete, we could be an insert or retain + // Insert + delete cancels out + } else if ( + typeof otherOp.delete === 'number' && + (typeof thisOp.retain === 'number' || + (typeof thisOp.retain === 'object' && thisOp.retain !== null)) + ) { + delta.push(otherOp); + } + } + } + return delta.chop(); + } + + concat(other: Delta): Delta { + const delta = new Delta(this.ops.slice()); + if (other.ops.length > 0) { + delta.push(other.ops[0]); + delta.ops = delta.ops.concat(other.ops.slice(1)); + } + return delta; + } + + diff(other: Delta): Delta { + if (this.ops === other.ops) { + return new Delta(); + } + const strings = this.deltasToStrings(other); + const diffResult = diffChars(strings[0], strings[1]); + const thisIter = new OpIterator(this.ops); + const otherIter = new OpIterator(other.ops); + const retDelta = this.convertChangesToDelta( + diffResult, + thisIter, + otherIter, + ); + + return retDelta.chop(); + } + + eachLine( + predicate: ( + line: Delta, + attributes: AttributeMap, + index: number, + ) => boolean | void, + newline = '\n', + ): void { + const iter = new OpIterator(this.ops); + let line = new Delta(); + let i = 0; + while (iter.hasNext()) { + if (iter.peekType() !== 'insert') { + return; + } + const thisOp = iter.peek(); + const start = Op.length(thisOp) - iter.peekLength(); + const index = + typeof thisOp.insert === 'string' + ? thisOp.insert.indexOf(newline, start) - start + : -1; + if (index < 0) { + line.push(iter.next()); + } else if (index > 0) { + line.push(iter.next(index)); + } else { + if (predicate(line, iter.next(1).attributes || {}, i) === false) { + return; + } + i += 1; + line = new Delta(); + } + } + if (line.length() > 0) { + predicate(line, {}, i); + } + } + + invert(base: Delta): Delta { + const inverted = new Delta(); + this.reduce((baseIndex, op) => { + if (op.insert) { + inverted.delete(Op.length(op)); + } else if (typeof op.retain === 'number' && op.attributes == null) { + inverted.retain(op.retain); + return baseIndex + op.retain; + } else if (op.delete || typeof op.retain === 'number') { + const length = (op.delete || op.retain) as number; + const slice = base.slice(baseIndex, baseIndex + length); + slice.forEach((baseOp) => { + if (op.delete) { + inverted.push(baseOp); + } else if (op.retain && op.attributes) { + inverted.retain( + Op.length(baseOp), + AttributeMap.invert(op.attributes, baseOp.attributes), + ); + } + }); + return baseIndex + length; + } else if (typeof op.retain === 'object' && op.retain !== null) { + const slice = base.slice(baseIndex, baseIndex + 1); + const baseOp = new OpIterator(slice.ops).next(); + const [embedType, opData, baseOpData] = getEmbedTypeAndData( + op.retain, + baseOp.insert, + ); + const handler = Delta.getHandler(embedType); + inverted.retain( + { [embedType]: handler.invert(opData, baseOpData) }, + AttributeMap.invert(op.attributes, baseOp.attributes), + ); + return baseIndex + 1; + } + return baseIndex; + }, 0); + return inverted.chop(); + } + + transform(index: number, priority?: boolean): number; + transform(other: Delta, priority?: boolean): Delta; + transform(arg: number | Delta, priority = false): typeof arg { + priority = !!priority; + if (typeof arg === 'number') { + return this.transformPosition(arg, priority); + } + const other: Delta = arg; + const thisIter = new OpIterator(this.ops); + const otherIter = new OpIterator(other.ops); + const delta = new Delta(); + while (thisIter.hasNext() || otherIter.hasNext()) { + if ( + thisIter.peekType() === 'insert' && + (priority || otherIter.peekType() !== 'insert') + ) { + delta.retain(Op.length(thisIter.next())); + } else if (otherIter.peekType() === 'insert') { + delta.push(otherIter.next()); + } else { + const length = Math.min(thisIter.peekLength(), otherIter.peekLength()); + const thisOp = thisIter.next(length); + const otherOp = otherIter.next(length); + if (thisOp.delete) { + // Our delete either makes their delete redundant or removes their retain + continue; + } else if (otherOp.delete) { + delta.push(otherOp); + } else { + const thisData = thisOp.retain; + const otherData = otherOp.retain; + let transformedData: Op['retain'] = + typeof otherData === 'object' && otherData !== null + ? otherData + : length; + if ( + typeof thisData === 'object' && + thisData !== null && + typeof otherData === 'object' && + otherData !== null + ) { + const embedType = Object.keys(thisData)[0]; + if (embedType === Object.keys(otherData)[0]) { + const handler = Delta.getHandler(embedType); + if (handler) { + transformedData = { + [embedType]: handler.transform( + thisData[embedType], + otherData[embedType], + priority, + ), + }; + } + } + } + + // We retain either their retain or insert + delta.retain( + transformedData, + AttributeMap.transform( + thisOp.attributes, + otherOp.attributes, + priority, + ), + ); + } + } + } + return delta.chop(); + } + + transformPosition(index: number, priority = false): number { + priority = !!priority; + const thisIter = new OpIterator(this.ops); + let offset = 0; + while (thisIter.hasNext() && offset <= index) { + const length = thisIter.peekLength(); + const nextType = thisIter.peekType(); + thisIter.next(); + if (nextType === 'delete') { + index -= Math.min(length, index - offset); + continue; + } else if (nextType === 'insert' && (offset < index || !priority)) { + index += length; + } + offset += length; + } + return index; + } + + /** + * Given a Delta and a cursor position, do a diff and attempt to adjust + * the diff to place insertions or deletions at the cursor position. + * + * @param other - The other Delta to diff against. + * @param cursorAfterChange - The cursor position index after the change. + * @returns A Delta that attempts to place insertions or deletions at the cursor position. + */ + diffWithCursor(other: Delta, cursorAfterChange: number | null): Delta { + if (this.ops === other.ops) { + return new Delta(); + } else if (cursorAfterChange === null) { + // If no cursor position is provided, do a regular diff. + return this.diff(other); + } + + const strings = this.deltasToStrings(other); + let diffs = diffChars(strings[0], strings[1]); + let lastDiffPosition = 0; + const adjustedDiffs: Change[] = []; + + for (let i = 0; i < diffs.length; i++) { + const diff = diffs[i]; + + const segmentStart = lastDiffPosition; + const segmentEnd = lastDiffPosition + diff.count; + const isCursorInSegment = + cursorAfterChange > segmentStart && + cursorAfterChange <= segmentEnd; + + const isUnchangedSegment = !diff.added && !diff.removed; + const isRemovalSegment = diff.removed && !diff.added; + + const nextDiff = diffs[i + 1]; + const isNextDiffAnInsert = + nextDiff && nextDiff.added && !nextDiff.removed; + + // Path 1: Look-ahead strategy + // If the position of the cursor is in an "unchanged" segment, but there's an insertion + // right after this section, then the insertion has likely been placed in + // the incorrect spot, and we can move the insertion to the position of the cursor. + if ( + isUnchangedSegment && + isCursorInSegment && + isNextDiffAnInsert + ) { + const movedSegments = this.tryMoveInsertionToCursor( + diff, + nextDiff, + cursorAfterChange, + segmentStart, + ); + + if (movedSegments) { + adjustedDiffs.push(...movedSegments); + // Skip the next diff since we've already consumed it + i++; + lastDiffPosition = segmentEnd; + continue; + } + } + + // Path 2: Look-back strategy + // Handle removals by checking if cursor was in the previous unchanged segment + if (isRemovalSegment) { + const movedSegments = this.tryMoveDeletionToCursor( + diff, + adjustedDiffs, + cursorAfterChange, + lastDiffPosition, + ); + + if (movedSegments) { + // Remove the previous unchanged segment from adjustedDiffs + adjustedDiffs.pop(); + adjustedDiffs.push(...movedSegments); + lastDiffPosition += diff.count; + continue; + } + } + + // Path 3: Do nothing - add diff as-is + adjustedDiffs.push(diff); + if (!diff.added) { + lastDiffPosition += diff.count; + } + } + + diffs = adjustedDiffs; + + const thisIter = new OpIterator(this.ops); + const otherIter = new OpIterator(other.ops); + const retDelta = this.convertChangesToDelta(diffs, thisIter, otherIter); + + return retDelta.chop(); + } + + /** + * Try to move an insertion operation from after an unchanged segment to the cursor position within it. + * This is a "look-ahead" strategy. + * + * @param diff - The current unchanged diff segment. + * @param nextDiff - The next diff segment (expected to be an insertion). + * @param cursorAfterChange - The cursor position after the change. + * @param segmentStart - The start position of the current segment. + * @returns An array of adjusted diff segments if the insertion was successfully moved, null otherwise. + */ + private tryMoveInsertionToCursor( + diff: Change, + nextDiff: Change, + cursorAfterChange: number, + segmentStart: number, + ): Change[] | null { + const nextDiffInsert = nextDiff.value; + const insertLength = nextDiffInsert.length; + const insertOffset = cursorAfterChange - segmentStart - insertLength; + + // Verify that the inserted text matches the text at the cursor position + const textAtCursor = diff.value.substring( + insertOffset, + insertOffset + nextDiffInsert.length, + ); + const isInsertMoveable = textAtCursor === nextDiffInsert; + + // The insert text matches what's at the cursor position, + // so we can safely move the insertion to the cursor position. + if (!isInsertMoveable) { + return null; + } + + // Split the current segment at the cursor + const beforeCursor = diff.value.substring(0, insertOffset); + const afterCursor = diff.value.substring(insertOffset); + + const result: Change[] = []; + + // Add before cursor part (if not empty) + if (beforeCursor.length > 0) { + result.push({ + value: beforeCursor, + count: beforeCursor.length, + added: false, + removed: false, + }); + } + + // Add the insertion in the middle + result.push(nextDiff); + + // Add after cursor part (if not empty) + if (afterCursor.length > 0) { + result.push({ + value: afterCursor, + count: afterCursor.length, + added: false, + removed: false, + }); + } + + return result; + } + + /** + * Try to move a deletion operation to the cursor position by looking back at the previous unchanged segment. + * This is a "look-back" strategy. + * + * @param diff - The current deletion diff segment. + * @param adjustedDiffs - The array of previously processed diff segments. + * @param cursorAfterChange - The cursor position after the change. + * @param lastDiffPosition - The position in the document up to (but not including) the current diff. + * @returns An array of adjusted diff segments if the deletion was successfully moved, null otherwise. + */ + private tryMoveDeletionToCursor( + diff: Change, + adjustedDiffs: Change[], + cursorAfterChange: number, + lastDiffPosition: number, + ): Change[] | null { + // Check if there's a preceding unchanged segment where cursor falls + // and the deleted characters match characters in that segment + const prevDiff = adjustedDiffs[adjustedDiffs.length - 1]; + + if (!prevDiff || prevDiff.added || prevDiff.removed) { + return null; + } + + const prevSegmentStart = lastDiffPosition - prevDiff.count; + const prevSegmentEnd = lastDiffPosition; + + // Check if cursor is within or at the end of the previous unchanged segment + if ( + cursorAfterChange < prevSegmentStart || + cursorAfterChange >= prevSegmentEnd + ) { + return null; + } + + // Check if the deleted characters match the text at the cursor position + const deletedChars = diff.value; + const deleteOffset = cursorAfterChange - prevSegmentStart; + const textAtCursor = prevDiff.value.substring( + deleteOffset, + deleteOffset + deletedChars.length, + ); + const canBePlacedHere = textAtCursor === deletedChars; + + if (!canBePlacedHere) { + return null; + } + + // Split the unchanged segment at the cursor and place deletion there + const beforeCursor = prevDiff.value.substring(0, deleteOffset); + const atAndAfterCursor = prevDiff.value.substring(deleteOffset); + + // The deletion should consume characters starting at cursor + const deletionLength = diff.count; + const afterDeletion = atAndAfterCursor.substring(deletionLength); + + const result: Change[] = []; + + // Add before cursor part (if not empty) + if (beforeCursor.length > 0) { + result.push({ + value: beforeCursor, + count: beforeCursor.length, + added: false, + removed: false, + }); + } + + // Add the deletion + result.push(diff); + + // Add after deletion part (if not empty) + if (afterDeletion.length > 0) { + result.push({ + value: afterDeletion, + count: afterDeletion.length, + added: false, + removed: false, + }); + } + + return result; + } + + /** + * Convert two Deltas to string representations for diffing. + * + * @param other - The other Delta to convert. + * @returns A tuple of [thisString, otherString]. + */ + private deltasToStrings(other: Delta): [string, string] { + return [this, other].map((delta) => { + return delta + .map((op) => { + if (op.insert != null) { + return typeof op.insert === 'string' + ? op.insert + : NULL_CHARACTER; + } + const prep = delta === other ? 'on' : 'with'; + throw new Error('diff() called ' + prep + ' non-document'); + }) + .join(''); + }) as [string, string]; + } + + /** + * Process diff changes and convert them to Delta operations. + * + * @param changes - The array of changes from the diff algorithm. + * @param thisIter - Iterator for this Delta's operations. + * @param otherIter - Iterator for the other Delta's operations. + * @returns A Delta containing the processed diff operations. + */ + private convertChangesToDelta( + changes: Change[], + thisIter: OpIterator, + otherIter: OpIterator, + ): Delta { + const retDelta = new Delta(); + changes.forEach((component: Change) => { + let length = component.count; + while (length > 0) { + let opLength = 0; + if (component.added) { + opLength = Math.min(otherIter.peekLength(), length); + retDelta.push(otherIter.next(opLength)); + } else if (component.removed) { + opLength = Math.min(length, thisIter.peekLength()); + thisIter.next(opLength); + retDelta.delete(opLength); + } else { + opLength = Math.min( + thisIter.peekLength(), + otherIter.peekLength(), + length, + ); + const thisOp = thisIter.next(opLength); + const otherOp = otherIter.next(opLength); + if (isEqual(thisOp.insert, otherOp.insert)) { + retDelta.retain( + opLength, + AttributeMap.diff(thisOp.attributes, otherOp.attributes), + ); + } else { + retDelta.push(otherOp).delete(opLength); + } + } + length -= opLength; + } + }); + return retDelta; + } +} + +export default Delta; +export { Op, OpIterator, AttributeMap }; diff --git a/packages/sync/src/quill-delta/LICENSE b/packages/sync/src/quill-delta/LICENSE new file mode 100644 index 00000000000000..25907e8cca0570 --- /dev/null +++ b/packages/sync/src/quill-delta/LICENSE @@ -0,0 +1,14 @@ +BSD 3-Clause License + +Copyright (c) 2022, Slab, Inc. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/packages/sync/src/quill-delta/Op.ts b/packages/sync/src/quill-delta/Op.ts new file mode 100644 index 00000000000000..50a55ed24e8f09 --- /dev/null +++ b/packages/sync/src/quill-delta/Op.ts @@ -0,0 +1,26 @@ +import AttributeMap from './AttributeMap'; + +interface Op { + // only one property out of {insert, delete, retain} will be present + insert?: string | Record; + delete?: number; + retain?: number | Record; + + attributes?: AttributeMap; +} + +namespace Op { + export function length(op: Op): number { + if (typeof op.delete === 'number') { + return op.delete; + } else if (typeof op.retain === 'number') { + return op.retain; + } else if (typeof op.retain === 'object' && op.retain !== null) { + return 1; + } else { + return typeof op.insert === 'string' ? op.insert.length : 1; + } + } +} + +export default Op; diff --git a/packages/sync/src/quill-delta/OpIterator.ts b/packages/sync/src/quill-delta/OpIterator.ts new file mode 100644 index 00000000000000..ff0411479bdfe9 --- /dev/null +++ b/packages/sync/src/quill-delta/OpIterator.ts @@ -0,0 +1,106 @@ +import Op from './Op'; + +export default class Iterator { + ops: Op[]; + index: number; + offset: number; + + constructor(ops: Op[]) { + this.ops = ops; + this.index = 0; + this.offset = 0; + } + + hasNext(): boolean { + return this.peekLength() < Infinity; + } + + next(length?: number): Op { + if (!length) { + length = Infinity; + } + const nextOp = this.ops[this.index]; + if (nextOp) { + const offset = this.offset; + const opLength = Op.length(nextOp); + if (length >= opLength - offset) { + length = opLength - offset; + this.index += 1; + this.offset = 0; + } else { + this.offset += length; + } + if (typeof nextOp.delete === 'number') { + return { delete: length }; + } else { + const retOp: Op = {}; + if (nextOp.attributes) { + retOp.attributes = nextOp.attributes; + } + if (typeof nextOp.retain === 'number') { + retOp.retain = length; + } else if ( + typeof nextOp.retain === 'object' && + nextOp.retain !== null + ) { + // offset should === 0, length should === 1 + retOp.retain = nextOp.retain; + } else if (typeof nextOp.insert === 'string') { + retOp.insert = nextOp.insert.substr(offset, length); + } else { + // offset should === 0, length should === 1 + retOp.insert = nextOp.insert; + } + return retOp; + } + } else { + return { retain: Infinity }; + } + } + + peek(): Op { + return this.ops[this.index]; + } + + peekLength(): number { + if (this.ops[this.index]) { + // Should never return 0 if our index is being managed correctly + return Op.length(this.ops[this.index]) - this.offset; + } else { + return Infinity; + } + } + + peekType(): string { + const op = this.ops[this.index]; + if (op) { + if (typeof op.delete === 'number') { + return 'delete'; + } else if ( + typeof op.retain === 'number' || + (typeof op.retain === 'object' && op.retain !== null) + ) { + return 'retain'; + } else { + return 'insert'; + } + } + return 'retain'; + } + + rest(): Op[] { + if (!this.hasNext()) { + return []; + } else if (this.offset === 0) { + return this.ops.slice(this.index); + } else { + const offset = this.offset; + const index = this.index; + const next = this.next(); + const rest = this.ops.slice(this.index); + this.offset = offset; + this.index = index; + return [next].concat(rest); + } + } +} diff --git a/packages/sync/src/test/Delta.ts b/packages/sync/src/test/Delta.ts new file mode 100644 index 00000000000000..146a89c7aa01b9 --- /dev/null +++ b/packages/sync/src/test/Delta.ts @@ -0,0 +1,496 @@ +/** + * External dependencies + */ +import { describe, expect, it } from '@jest/globals'; + +/** + * Internal dependencies + */ +import Delta from '../quill-delta/Delta'; + +describe( 'Delta.diffWithCursor', () => { + describe( 'insertions', () => { + it( 'should handle insertion at beginning', () => { + // '|aaa' -> 'a|aaa' + const oldDelta = new Delta().insert( 'aaa' ); + const newDelta = new Delta().insert( 'aaaa' ); + const cursorAfterChange = 1; // After adding an 'a' at the front + + const diff = oldDelta.diffWithCursor( newDelta, cursorAfterChange ); + + // Cursor at beginning - should still work correctly + expect( diff.ops ).toEqual( [ { insert: 'a' } ] ); + } ); + + it( 'should place insertion at cursor position in the middle of repeated characters', () => { + // 'a|aa' -> 'aa|aa' + const oldDelta = new Delta().insert( 'aaa' ); + const newDelta = new Delta().insert( 'aaaa' ); + const cursor = 2; // After adding an 'a' at the second character + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Should retain 1 character, insert 'a', then retain 3 more + expect( diff.ops ).toEqual( [ { retain: 1 }, { insert: 'a' } ] ); + } ); + + it( 'should place insertion at cursor position at the end of repeated characters', () => { + // 'aaa|' -> 'aaaa|' + const oldDelta = new Delta().insert( 'aaa' ); + const newDelta = new Delta().insert( 'aaaa' ); + const cursor = 4; // After adding an 'a' at the end + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Should retain 1 character, insert 'a', then retain 3 more + expect( diff.ops ).toEqual( [ { retain: 3 }, { insert: 'a' } ] ); + } ); + + it( 'should place insertion at cursor position in regular string', () => { + // 'hello |world' -> 'hello l|world' + const oldDelta = new Delta().insert( 'hello world' ); + const newDelta = new Delta().insert( 'hello lworld' ); + const cursor = 7; // After adding an 'l' before 'world' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 6 }, { insert: 'l' } ] ); + } ); + + it( 'should handle insertion in middle of non-repeated characters', () => { + // 'a|bc' -> 'ab|bc' + const oldDelta = new Delta().insert( 'abc' ); + const newDelta = new Delta().insert( 'abbc' ); + const cursor = 2; // After adding a 'b' after 'a' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 1 }, { insert: 'b' } ] ); + } ); + + it( 'should handle multi-character insertion', () => { + // 'a|aaaaa' -> 'aaaaa|aaaaa' + const oldDelta = new Delta().insert( 'aaaaaa' ); + const newDelta = new Delta().insert( 'aaaaaaaaaa' ); + const cursor = 5; // After adding 'aaaa' starting at the second character + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 1 }, { insert: 'aaaa' } ] ); + } ); + } ); + + describe( 'deletions', () => { + it( 'should place deletion at cursor position with repeated characters', () => { + // aa|aa -> a|aa + const oldDelta = new Delta().insert( 'aaaa' ); + const newDelta = new Delta().insert( 'aaa' ); + const cursor = 1; // After deleting the second 'a' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Should retain 1 character, delete 1, then retain 2 more + expect( diff.ops ).toEqual( [ { retain: 1 }, { delete: 1 } ] ); + } ); + + it( 'should place deletion at cursor position in a regular string', () => { + // hello l|world -> hello |world + const oldDelta = new Delta().insert( 'hello lworld' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 6; // After deleting the 'l' before 'world' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 6 }, { delete: 1 } ] ); + } ); + + it( 'should handle deletion at beginning', () => { + // 'a|aaa' -> '|aaa' + const oldDelta = new Delta().insert( 'aaaa' ); + const newDelta = new Delta().insert( 'aaa' ); + const cursor = 0; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Cursor at beginning + expect( diff.ops ).toEqual( [ { delete: 1 } ] ); + } ); + + it( 'should handle deletion in middle of non-repeated characters', () => { + // 'ab|bc' -> 'a|bc' + const oldDelta = new Delta().insert( 'abbc' ); + const newDelta = new Delta().insert( 'abc' ); + const cursor = 1; // After "ab", where the 'b' was deleted + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 1 }, { delete: 1 } ] ); + } ); + + it( 'should handle multi-character deletion', () => { + // 'aaaaa|aaaaa' -> 'a|aaaaa' + const oldDelta = new Delta().insert( 'aaaaaaaaaa' ); + const newDelta = new Delta().insert( 'aaaaaa' ); + const cursor = 1; // Delete "aaaa" until cursor position after the first 'a' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 1 }, { delete: 4 } ] ); + } ); + } ); + + describe( 'paste operations', () => { + it( 'should handle pasting text in the middle of content', () => { + // 'hello |world' -> 'hello beautiful |world' + const oldDelta = new Delta().insert( 'hello world' ); + const newDelta = new Delta().insert( 'hello beautiful world' ); + const cursor = 16; // After pasting 'beautiful ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 6 }, + { insert: 'beautiful ' }, + ] ); + } ); + + it( 'should handle pasting over selected text (replacement)', () => { + // 'hello [world]!' -> 'hello sunshine|!' (paste 'sunshine' replacing 'world') + const oldDelta = new Delta().insert( 'hello world!' ); + const newDelta = new Delta().insert( 'hello sunshine!' ); + const cursor = 14; // After 'sunshine' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Note: The diff algorithm struggles with this case because 'wonderful' and 'cruel' + // share some characters. The cursor hint helps but doesn't fully resolve the ambiguity. + // In a real editor, this would typically be handled by delete+insert operations. + expect( diff.ops ).toEqual( [ + { retain: 6 }, + { insert: 'sunshine' }, + { delete: 5 }, + ] ); + } ); + + it( 'should handle pasting at the beginning', () => { + // '|hello' -> 'pasted |hello' + const oldDelta = new Delta().insert( 'hello' ); + const newDelta = new Delta().insert( 'pasted hello' ); + const cursor = 7; // After 'pasted ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { insert: 'pasted ' } ] ); + } ); + + it( 'should handle pasting multi-line content', () => { + // 'line1|' -> 'line1\nline2\nline3|' + const oldDelta = new Delta().insert( 'line1' ); + const newDelta = new Delta().insert( 'line1\nline2\nline3' ); + const cursor = 17; // After the paste + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 5 }, + { insert: '\nline2\nline3' }, + ] ); + } ); + } ); + + describe( 'mixed operations', () => { + it( 'should handle typing to replace a character (insert + delete)', () => { + // 'helo|' -> 'hell|' (user notices typo, backspaces 'o', types 'l') + const oldDelta = new Delta().insert( 'helo' ); + const newDelta = new Delta().insert( 'hell' ); + const cursor = 4; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 3 }, + { insert: 'l' }, + { delete: 1 }, + ] ); + } ); + + it( 'should handle multiple character replacement in middle of word', () => { + // 'reci|eve' -> 'recei|ve' (fix typo by deleting and inserting) + const oldDelta = new Delta().insert( 'recieve' ); + const newDelta = new Delta().insert( 'receive' ); + const cursor = 5; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Note: Character-level diff sees 'i' -> delete, 'e' -> retain, 'i' -> insert + // This is correct behavior - the 'e' is shared between old and new + expect( diff.ops ).toEqual( [ + { retain: 3 }, + { delete: 1 }, + { retain: 1 }, + { insert: 'i' }, + ] ); + } ); + } ); + + describe( 'word boundary operations', () => { + it( 'should handle deleting a whole word with backspace', () => { + // 'hello world|' -> 'hello |' (delete 'world') + const oldDelta = new Delta().insert( 'hello world' ); + const newDelta = new Delta().insert( 'hello ' ); + const cursor = 6; // After 'hello ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 6 }, { delete: 5 } ] ); + } ); + + it( 'should handle adding spaces between words', () => { + // 'hello|world' -> 'hello |world' (add space in middle) + const oldDelta = new Delta().insert( 'helloworld' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 6; // After adding space + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 5 }, { insert: ' ' } ] ); + } ); + } ); + + describe( 'formatting with attributes', () => { + it( 'should handle insertion with attributes at cursor position', () => { + // 'hello |world' -> 'hello BOLD |world' + const oldDelta = new Delta().insert( 'hello world' ); + const newDelta = new Delta() + .insert( 'hello ' ) + .insert( 'bold', { bold: true } ) + .insert( ' world' ); + const cursor = 5; // After 'hello ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Note: The space before 'world' is seen as a separate insert because + // the formatted 'bold' text creates a boundary in the Delta ops. + // The diff correctly identifies the 'bold' insertion with attributes. + expect( diff.ops ).toEqual( [ + { retain: 6 }, + { insert: 'bold', attributes: { bold: true } }, + { insert: ' ' }, + ] ); + } ); + + it( 'should handle deleting formatted text at cursor position', () => { + // 'hello BOLD |world' -> 'hello |world' + const oldDelta = new Delta() + .insert( 'hello ' ) + .insert( 'bold', { bold: true } ) + .insert( ' world' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 6; // After deleting 'BOLD ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Note: The deletion correctly removes the 'bold' text. The two spaces + // in the result are from the original space after 'hello' and the space before 'world'. + expect( diff.ops ).toEqual( [ { retain: 6 }, { delete: 4 } ] ); + } ); + + it( 'should preserve attributes when inserting at cursor in formatted text', () => { + // 'hel|lo world' -> 'hell|lo world' + const oldDelta = new Delta().insert( 'hello', { bold: true } ); + const newDelta = new Delta().insert( 'helllo', { bold: true } ); + const cursor = 4; // After inserting extra 'l' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 3 }, + { insert: 'l', attributes: { bold: true } }, + ] ); + } ); + } ); + + describe( 'end of document operations', () => { + it( 'should handle adding content at the very end', () => { + // 'hello|' -> 'hello world|' + const oldDelta = new Delta().insert( 'hello' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 11; // At the end + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 5 }, + { insert: ' world' }, + ] ); + } ); + + it( 'should handle deleting from the end', () => { + // 'hello world|' -> 'hello|' (delete ' world') + const oldDelta = new Delta().insert( 'hello world' ); + const newDelta = new Delta().insert( 'hello' ); + const cursor = 5; // After 'hello' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 5 }, { delete: 6 } ] ); + } ); + + it( 'should handle appending to empty document', () => { + // '|' -> 'hello|' + const oldDelta = new Delta().insert( '' ); + const newDelta = new Delta().insert( 'hello' ); + const cursor = 5; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { insert: 'hello' } ] ); + } ); + } ); + + describe( 'IME and composition text', () => { + it( 'should handle character composition', () => { + // Typing Japanese: 'n' -> 'ni' -> 'に' + // Simulating intermediate state: 'helloに|world' + // 'helloni|world' -> 'helloに|world' + const oldDelta = new Delta().insert( 'helloniworld' ); + const newDelta = new Delta().insert( 'helloにworld' ); + const cursor = 6; // After the composed character + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 5 }, + { insert: 'に' }, + { delete: 2 }, + ] ); + } ); + + it( 'should handle multiple character changes during composition', () => { + // Composing Korean or Chinese where multiple chars change + // 'hello gam| world' -> 'hello 감| world' + const oldDelta = new Delta().insert( 'hello gam world' ); + const newDelta = new Delta().insert( 'hello 감 world' ); + const cursor = 7; // After composition completes + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 6 }, + { insert: '감' }, + { delete: 3 }, + ] ); + } ); + + it( 'should handle composition replacement in middle of text', () => { + // User types 'a' then it becomes 'あ' through IME + // 'helloa|world' -> 'helloあ|world' + const oldDelta = new Delta().insert( 'helloaworld' ); + const newDelta = new Delta().insert( 'helloあworld' ); + const cursor = 6; // After 'helloあ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 5 }, + { insert: 'あ' }, + { delete: 1 }, + ] ); + } ); + } ); + + describe( 'whitespace handling', () => { + it( 'should handle multiple spaces insertion', () => { + // 'hello|world' -> 'hello |world' (add 3 spaces) + const oldDelta = new Delta().insert( 'helloworld' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 8; // After adding 3 spaces + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 5 }, { insert: ' ' } ] ); + } ); + + it( 'should handle tab insertion', () => { + // 'hello|world' -> 'hello\t|world' + const oldDelta = new Delta().insert( 'helloworld' ); + const newDelta = new Delta().insert( 'hello\tworld' ); + const cursor = 6; // After 'hello\t' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 5 }, { insert: '\t' } ] ); + } ); + + it( 'should handle trailing whitespace addition', () => { + // 'hello|' -> 'hello |' (add trailing spaces) + const oldDelta = new Delta().insert( 'hello' ); + const newDelta = new Delta().insert( 'hello ' ); + const cursor = 8; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 5 }, { insert: ' ' } ] ); + } ); + + it( 'should handle leading whitespace addition', () => { + // '|hello' -> ' |hello' (add leading spaces) + const oldDelta = new Delta().insert( 'hello' ); + const newDelta = new Delta().insert( ' hello' ); + const cursor = 3; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { insert: ' ' } ] ); + } ); + + it( 'should handle whitespace deletion', () => { + // 'hello |world' -> 'hello |world' (delete 2 spaces) + const oldDelta = new Delta().insert( 'hello world' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 6; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ { retain: 6 }, { delete: 2 } ] ); + } ); + + it( 'should handle mixed whitespace types', () => { + // 'hello\t|world' -> 'hello |world' (replace tab with spaces) + const oldDelta = new Delta().insert( 'hello\tworld' ); + const newDelta = new Delta().insert( 'hello world' ); + const cursor = 7; // After 'hello ' + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [ + { retain: 5 }, + { insert: ' ' }, + { delete: 1 }, + ] ); + } ); + } ); + + describe( 'edge cases', () => { + it( 'should handle no changes', () => { + const oldDelta = new Delta().insert( 'hello' ); + const newDelta = new Delta().insert( 'hello' ); + const cursor = 2; + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + expect( diff.ops ).toEqual( [] ); + } ); + + it( 'should fallback to default diff behavior when cursor hint does not help', () => { + const oldDelta = new Delta().insert( 'abc' ); + const newDelta = new Delta().insert( 'abcd' ); + const cursor = 1; // Cursor at 1, but insertion is at end + + const diff = oldDelta.diffWithCursor( newDelta, cursor ); + + // Since 'd' is not at cursor position, should fall back to default + expect( diff.ops ).toEqual( [ { retain: 3 }, { insert: 'd' } ] ); + } ); + } ); +} );