diff --git a/package-lock.json b/package-lock.json index 576a1199a4513d..321e141fd06ad8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14728,6 +14728,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/diff": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", + "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", + "license": "MIT" + }, "node_modules/@types/doctrine": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", @@ -54931,9 +54937,12 @@ "version": "1.37.0", "license": "GPL-2.0-or-later", "dependencies": { + "@types/diff": "7.0.2", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", + "diff": "4.0.2", + "fast-deep-equal": "3.1.3", "lib0": "^0.2.42", "y-protocols": "^1.0.5", "yjs": "~13.6.6" diff --git a/packages/core-data/src/utils/crdt-blocks.ts b/packages/core-data/src/utils/crdt-blocks.ts index ea7fe2af0b6787..fb4589ed06b636 100644 --- a/packages/core-data/src/utils/crdt-blocks.ts +++ b/packages/core-data/src/utils/crdt-blocks.ts @@ -7,16 +7,10 @@ import fastDeepEqual from 'fast-deep-equal/es6/index.js'; /** * WordPress dependencies */ -import { RichTextData } from '@wordpress/rich-text'; -import { Y } from '@wordpress/sync'; - // @ts-expect-error No exported types. import { getBlockTypes } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import type { WPBlockSelection } from '../types'; +import { RichTextData } from '@wordpress/rich-text'; +import { Y, Delta } from '@wordpress/sync'; /** * Internal dependencies @@ -192,13 +186,13 @@ function createNewYBlock( block: Block ): YBlock { * 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, either from a peer or from the local editor. - * @param lastSelection The last cursor position, used for hinting the diff algorithm. + * @param incomingBlocks Gutenberg blocks being synced. + * @param cursorPosition The position of the cursor after the change occurs. */ export function mergeCrdtBlocks( yblocks: YBlocks, incomingBlocks: Block[], - lastSelection: WPBlockSelection | null + cursorPosition: number | null ): void { // Ensure we are working with serializable block data. if ( ! serializableBlocksCache.has( incomingBlocks ) ) { @@ -315,7 +309,7 @@ export function mergeCrdtBlocks( mergeRichTextUpdate( currentAttribute, attributeValue, - lastSelection + cursorPosition ); } else { currentAttributes.set( @@ -351,7 +345,11 @@ export function mergeCrdtBlocks( yblock.set( key, yInnerBlocks ); } - mergeCrdtBlocks( yInnerBlocks, value ?? [], lastSelection ); + mergeCrdtBlocks( + yInnerBlocks, + value ?? [], + cursorPosition + ); break; } @@ -464,22 +462,21 @@ function isRichTextAttribute( ); } +let localDoc: Y.Doc; + /** * 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, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - lastSelection: WPBlockSelection | null + cursorPosition: number | null ): void { - // TODO - // ==== // Gutenberg does not use Yjs shared types natively, so we can only subscribe // to changes from store and apply them to Yjs types that we create and // manage. Crucially, for rich-text attributes, we do not receive granular @@ -487,31 +484,24 @@ function mergeRichTextUpdate( // only a single character changed. // // The code below allows us to compute a delta between the current and new - // value, then apply it to the Y.Text. However, it relies on a library - // (quill-delta) with a licensing issue that we are working to resolve. - // - // For now, we simply replace the full text content on each change. - // - // 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(); - // } + // value, then apply it to the Y.Text. - // 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() ); + 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 deltaDiff = currentValueAsDelta.diff( - // updatedValueAsDelta, - // lastSelection?.offset - // ); + const localYText = localDoc.getText( 'temporary-text' ); + localYText.delete( 0, localYText.length ); + localYText.insert( 0, updatedValue ); - // blockYText.applyDelta( deltaDiff.ops ); + const currentValueAsDelta = new Delta( blockYText.toDelta() ); + const updatedValueAsDelta = new Delta( localYText.toDelta() ); + const deltaDiff = currentValueAsDelta.diffWithCursor( + updatedValueAsDelta, + cursorPosition + ); - blockYText.delete( 0, blockYText.toString().length ); - blockYText.insert( 0, updatedValue ); + blockYText.applyDelta( deltaDiff.ops ); } diff --git a/packages/core-data/src/utils/crdt.ts b/packages/core-data/src/utils/crdt.ts index d383364fe5e5db..37cf0b6f21f348 100644 --- a/packages/core-data/src/utils/crdt.ts +++ b/packages/core-data/src/utils/crdt.ts @@ -26,7 +26,7 @@ import { CRDT_RECORD_MAP_KEY, WORDPRESS_META_KEY_FOR_CRDT_DOC_PERSISTENCE, } from '../sync'; -import type { WPBlockSelection, WPSelection } from '../types'; +import type { WPSelection } from '../types'; import { createYMap, getRootMap, @@ -62,9 +62,6 @@ export interface YPostRecord extends YMapRecord { title: string; } -// Hold a reference to the last known selection to help compute Y.Text deltas. -let lastSelection: WPBlockSelection | null = null; - // Properties that are allowed to be synced for a post. const allowedPostProperties = new Set< string >( [ 'author', @@ -161,9 +158,14 @@ 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 ); + mergeCrdtBlocks( currentBlocks, newBlocks, cursorPosition ); break; } @@ -238,11 +240,6 @@ export function applyPostChangesToCRDTDoc( } } } ); - - // Update the lastSelection for use in computing Y.Text deltas. - if ( 'selection' in changes ) { - lastSelection = changes.selection?.selectionStart ?? null; - } } export function defaultGetChangesFromCRDTDoc( crdtDoc: CRDTDoc ): ObjectData { diff --git a/packages/sync/README.md b/packages/sync/README.md index 26c900f350a0fb..a6b8fba8df7680 100644 --- a/packages/sync/README.md +++ b/packages/sync/README.md @@ -26,6 +26,10 @@ Root-level key for the CRDT document that holds the entity record data. The sync manager orchestrates the lifecycle of syncing entity records. It creates Yjs documents, connects to providers, creates awareness instances, and coordinates with the `core-data` store. +### Delta + +Deltas are used to calculate incremental Y.Text updates. + ### LOCAL_EDITOR_ORIGIN Origin string for CRDT document changes originating from the local editor. diff --git a/packages/sync/package.json b/packages/sync/package.json index fcb9a3069161a5..4d6500f1d80098 100644 --- a/packages/sync/package.json +++ b/packages/sync/package.json @@ -43,9 +43,12 @@ "types": "build-types", "sideEffects": false, "dependencies": { + "@types/diff": "7.0.2", "@wordpress/hooks": "file:../hooks", "@wordpress/undo-manager": "file:../undo-manager", "@wordpress/url": "file:../url", + "diff": "4.0.2", + "fast-deep-equal": "3.1.3", "lib0": "^0.2.42", "y-protocols": "^1.0.5", "yjs": "~13.6.6" diff --git a/packages/sync/src/index.ts b/packages/sync/src/index.ts index 3d5b2cb0d555fd..1aa27a30724100 100644 --- a/packages/sync/src/index.ts +++ b/packages/sync/src/index.ts @@ -11,6 +11,11 @@ */ export * as Y from 'yjs'; +/** + * Deltas are used to calculate incremental Y.Text updates. + */ +export { default as Delta } from './quill-delta/Delta'; + export { CRDT_DOC_META_PERSISTENCE_KEY, CRDT_RECORD_MAP_KEY, diff --git a/packages/sync/src/quill-delta/AttributeMap.ts b/packages/sync/src/quill-delta/AttributeMap.ts new file mode 100644 index 00000000000000..c1fc4519c7eb44 --- /dev/null +++ b/packages/sync/src/quill-delta/AttributeMap.ts @@ -0,0 +1,126 @@ +// File copied https://github.com/slab/delta/blob/main/src/AttributeMap.ts with changes: +// - lodash.clonedeep is replaced with JSON parse / stringify +// - lodash.isequal is replaced with fast-deep-equal. + +/** + * External dependencies + */ +import { default as isEqual } from 'fast-deep-equal/es6'; + +function cloneDeep< T >( value: T ): T { + return JSON.parse( JSON.stringify( value ) ) as T; +} + +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< AttributeMap >( + ( copy, key ) => { + if ( + attributes[ key ] !== null || + attributes[ key ] !== undefined + ) { + 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< AttributeMap >( ( 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< AttributeMap >( + ( memo, key ) => { + if ( + base[ key ] !== attr[ key ] && + attr[ key ] !== undefined + ) { + memo[ key ] = base[ key ]; + } + return memo; + }, + {} + ); + return Object.keys( attr ).reduce< AttributeMap >( ( 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< AttributeMap >( + ( 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..bef16b16f31f02 --- /dev/null +++ b/packages/sync/src/quill-delta/Delta.ts @@ -0,0 +1,925 @@ +// File copied https://github.com/slab/delta/blob/main/src/Delta.ts with changes: +// - fast-diff swapped out for 'diff', +// - lodash.clonedeep is replaced with JSON parse / stringify +// - lodash.isequal is replaced with fast-deep-equal. + +// @ts-ignore +/** + * External dependencies + */ +import type { Change } from 'diff'; +import { diffChars } from 'diff'; +import { default as isEqual } from 'fast-deep-equal/es6'; + +/** + * Internal dependencies + */ +import AttributeMap from './AttributeMap'; +import Op from './Op'; +import OpIterator from './OpIterator'; + +function cloneDeep< T >( value: T ): T { + return JSON.parse( JSON.stringify( value ) ) as T; +} + +const NULL_CHARACTER = String.fromCharCode( 0 ); // Placeholder char for embed in diff() + +interface EmbedHandler< T > { + 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< unknown >; + } = {}; + + static registerEmbed< T >( + embedType: string, + handler: EmbedHandler< T > + ): void { + this.handlers[ embedType ] = handler as EmbedHandler< unknown >; + } + + static unregisterEmbed( embedType: string ): void { + delete this.handlers[ embedType ]; + } + + private static getHandler( embedType: string ): EmbedHandler< unknown > { + 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 && + ops !== undefined && + Array.isArray( ops.ops ) + ) { + this.ops = ops.ops; + } else { + this.ops = []; + } + } + + insert( + arg: string | Record< string, unknown >, + attributes?: AttributeMap | null + ): this { + const newOp: Op = {}; + if ( typeof arg === 'string' && arg.length === 0 ) { + return this; + } + newOp.insert = arg; + if ( + attributes !== null && + attributes !== undefined && + 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< string, unknown >, + attributes?: AttributeMap | null + ): this { + if ( typeof length === 'number' && length <= 0 ) { + return this; + } + const newOp: Op = { retain: length }; + if ( + attributes !== null && + attributes !== undefined && + 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 && + newOp.insert !== undefined + ) { + 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< T >( 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< T >( + 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 && + firstOther !== undefined && + typeof firstOther.retain === 'number' && + ( firstOther.attributes === null || + firstOther.attributes === undefined ) + ) { + 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 || + thisOp.retain === undefined + ) { + newOp.insert = thisOp.insert; + } else { + newOp.retain = thisOp.retain; + } + } else { + const action = + thisOp.retain === null || + thisOp.retain === undefined + ? '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 || op.attributes === undefined ) + ) { + 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. + * @return 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 ?? 0 ); + 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 ?? 0; + continue; + } + } + + // Path 3: Do nothing - add diff as-is + adjustedDiffs.push( diff ); + if ( ! diff.added ) { + lastDiffPosition += diff.count ?? 0; + } + } + + 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. + * @return 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. + * @return 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 ?? 0 ); + 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 ?? 0; + 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. + * @return A tuple of [thisString, otherString]. + */ + private deltasToStrings( other: Delta ): [ string, string ] { + return [ this, other ].map( ( delta ) => { + return delta + .map( ( op ) => { + if ( op.insert !== null || op.insert !== undefined ) { + 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. + * @return 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 ?? 0; + 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..0da83c0e999c88 --- /dev/null +++ b/packages/sync/src/quill-delta/Op.ts @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import type AttributeMap from './AttributeMap'; + +interface Op { + // only one property out of {insert, delete, retain} will be present + insert?: string | Record< string, unknown >; + delete?: number; + retain?: number | Record< string, unknown >; + + 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; + } + 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..3520e28620a75e --- /dev/null +++ b/packages/sync/src/quill-delta/OpIterator.ts @@ -0,0 +1,104 @@ +/** + * Internal dependencies + */ +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 }; + } + 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; + } + 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; + } + 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'; + } + return 'insert'; + } + return 'retain'; + } + + rest(): Op[] { + if ( ! this.hasNext() ) { + return []; + } else if ( this.offset === 0 ) { + return this.ops.slice( this.index ); + } + 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/quill-delta/README.md b/packages/sync/src/quill-delta/README.md new file mode 100644 index 00000000000000..afdbb52745fac6 --- /dev/null +++ b/packages/sync/src/quill-delta/README.md @@ -0,0 +1,9 @@ +# quill-delta fork + +## Why is this library here? + +This is a fork of the `quill-delta` npm package, which is used to apply incremental text updates to `Y.Text` with `Delta` objects. We were not able to use the `quill-delta` package directly due to an internal dependency on the `fast-diff` package, which is Apache v2 licensed, and not compatible with Gutenberg and WordPress' GPLv2 license. + +The `fast-diff` library in this fork has been replaced by `diff`, a different license-compatible diff implementation. Additionally, we've added the `diffWithCursor()` function to the `Delta` class that adjusts the output of `diff` to adjust calculated changes to match the user's active cursor location. + +More information available in the PR: https://github.com/WordPress/gutenberg/pull/72604 diff --git a/packages/sync/src/quill-delta/test/Delta.ts b/packages/sync/src/quill-delta/test/Delta.ts new file mode 100644 index 00000000000000..ebb9ceb13cbf77 --- /dev/null +++ b/packages/sync/src/quill-delta/test/Delta.ts @@ -0,0 +1,461 @@ +/** + * External dependencies + */ +import { describe, expect, it } from '@jest/globals'; + +/** + * Internal dependencies + */ +import Delta from '../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( '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' } ] ); + } ); + } ); +} );