diff --git a/packages/collaboration-manager/src/BatchedOperation.spec.ts b/packages/collaboration-manager/src/BatchedOperation.spec.ts new file mode 100644 index 0000000..772e703 --- /dev/null +++ b/packages/collaboration-manager/src/BatchedOperation.spec.ts @@ -0,0 +1,163 @@ +import type { Index } from '@editorjs/model'; +import { createDataKey, IndexBuilder, type TextRange } from '@editorjs/model'; +import { BatchedOperation } from './BatchedOperation.js'; +import type { SerializedOperation } from './Operation.js'; +import { Operation, OperationType } from './Operation.js'; + +const createIndexByRange = (range: TextRange): Index => new IndexBuilder() + .addBlockIndex(0) + .addDataKey(createDataKey('key')) + .addTextRange(range) + .build(); + +const templateIndex = createIndexByRange([0, 0]); + +const userId = 'user'; + +describe('Batch', () => { + it('should add Insert operation to batch', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + batch.add(op2); + + const operations = batch.operations; + + expect(operations).toEqual([op1, op2]); + }); + + it('should add Delete operation to batch', () => { + const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + batch.add(op2); + + const operations = batch.operations; + + expect(operations).toEqual([op1, op2]); + }); + + describe('from()', () => { + it('should create a new batch from an existing batch', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const originalBatch = new BatchedOperation(op1); + + originalBatch.add(op2); + + + const newBatch = BatchedOperation.from(originalBatch); + + expect(newBatch.operations).toStrictEqual(originalBatch.operations); + expect(newBatch).not.toBe(originalBatch); // Should be a new instance + }); + + it('should create a new batch from serialized operation', () => { + const serializedOp: SerializedOperation = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId).serialize(); + + const batch = BatchedOperation.from(serializedOp); + + expect(batch.operations[0].type).toBe(serializedOp.type); + expect(batch.operations[0].data).toEqual(serializedOp.data); + }); + }); + + describe('inverse()', () => { + it('should inverse all operations in the batch', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + batch.add(op2); + + const inversedBatch = batch.inverse(); + + expect(inversedBatch.operations[0].type).toBe(OperationType.Delete); + expect(inversedBatch.operations[1].type).toBe(OperationType.Delete); + }); + }); + + describe('transform()', () => { + it('should transform operations against another operation', () => { + const op1 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, createIndexByRange([2, 2]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + batch.add(op2); + + const againstOp = new Operation(OperationType.Insert, createIndexByRange([0, 0]), { payload: 'x' }, 'other-user'); + + const transformedBatch = batch.transform(againstOp); + + expect(transformedBatch).not.toBeNull(); + expect(transformedBatch!.operations.length).toBe(2); + // Check if text ranges were shifted by 1 due to insertion + /* eslint-disable @typescript-eslint/no-magic-numbers */ + expect(transformedBatch!.operations[0].index.textRange![0]).toBe(2); + expect(transformedBatch!.operations[1].index.textRange![0]).toBe(3); + /* eslint-enable @typescript-eslint/no-magic-numbers */ + }); + + it('should return batch with Neutral operations if no operations can be transformed', () => { + const op = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'a' }, userId); + + const batch = new BatchedOperation(op); + + const deleteIndex = createIndexByRange([0, 2]); + + // An operation that would make transformation impossible + const againstOp = new Operation(OperationType.Delete, deleteIndex, { payload: 'a' }, 'other-user'); + + const transformedBatch = batch.transform(againstOp); + + const neutralOp = new Operation(OperationType.Neutral, createIndexByRange([1, 1]), { payload: [] }, userId); + + expect(transformedBatch.operations[0]).toEqual(neutralOp); + }); + }); + + describe('canAdd()', () => { + it('should return true for consecutive text operations of same type', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + expect(batch.canAdd(op2)).toBe(true); + }); + + it('should return false for non-consecutive text operations', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, createIndexByRange([2, 2]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + expect(batch.canAdd(op2)).toBe(false); + }); + + it('should return false for different operation types', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Delete, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + expect(batch.canAdd(op2)).toBe(false); + }); + + it('should return false for modify operations', () => { + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Modify, createIndexByRange([1, 1]), { payload: 'b' }, userId); + + const batch = new BatchedOperation(op1); + + expect(batch.canAdd(op2)).toBe(false); + }); + }); +}); diff --git a/packages/collaboration-manager/src/BatchedOperation.ts b/packages/collaboration-manager/src/BatchedOperation.ts new file mode 100644 index 0000000..a6951b9 --- /dev/null +++ b/packages/collaboration-manager/src/BatchedOperation.ts @@ -0,0 +1,151 @@ +import type { InvertedOperationType } from './Operation.js'; +import { Operation, OperationType, type SerializedOperation } from './Operation.js'; + +/** + * Class to batch Text operations (maybe others in the future) for Undo/Redo purposes + */ +export class BatchedOperation extends Operation { + /** + * Array of operations to batch + */ + public operations: (Operation | Operation)[] = []; + + /** + * Batch constructor function + * + * @param firstOperation - first operation to add + */ + constructor(firstOperation: Operation | Operation) { + super(firstOperation.type, firstOperation.index, firstOperation.data, firstOperation.userId, firstOperation.rev); + + if (firstOperation !== undefined) { + this.add(firstOperation); + } + } + + /** + * Create a new operation batch from an array of operations + * + * @param opBatch - operation batch to clone + */ + public static from(opBatch: BatchedOperation): BatchedOperation; + + /** + * Create a new operation batch from a serialized operation + * + * @param json - serialized operation + */ + public static from(json: SerializedOperation): BatchedOperation; + + /** + * Create a new operation batch from an operation batch or a serialized operation + * + * @param opBatchOrJSON - operation batch or serialized operation + */ + public static from(opBatchOrJSON: BatchedOperation | SerializedOperation): BatchedOperation { + if (opBatchOrJSON instanceof BatchedOperation) { + /** + * Every batch should have at least one operation + */ + const batch = new BatchedOperation(Operation.from(opBatchOrJSON.operations[0])); + + opBatchOrJSON.operations.slice(1).forEach((op) => { + /** + * Deep clone operation to the new batch + */ + batch.add(Operation.from(op)); + }); + + return batch as BatchedOperation; + } else { + const batch = new BatchedOperation(Operation.from(opBatchOrJSON)); + + return batch; + } + } + + /** + * Adds an operation to the batch + * Make sure, that operation could be added to the batch + * + * @param op - operation to add + */ + public add(op: Operation | Operation): void { + this.operations.push(op); + } + + /** + * Method that inverses all of the operations in the batch + * + * @returns {BatchedOperation>} new batch with inversed operations + */ + public inverse(): BatchedOperation> { + const lastOp = this.operations[this.operations.length - 1]; + + /** + * Every batch should have at least one operation + */ + const newBatchedOperation = new BatchedOperation>(lastOp.inverse()); + + this.operations.toReversed().slice(1) + .map(op => newBatchedOperation.add(op.inverse())); + + return newBatchedOperation as BatchedOperation>; + } + + /** + * Method that transforms all of the operations in the batch against another operation + * + * @param againstOp - operation to transform against + * @returns {BatchedOperation} new batch with transformed operations + */ + public transform(againstOp: Operation): BatchedOperation { + const transformedOp = this.operations[0].transform(againstOp); + + const newBatchedOperation = new BatchedOperation(transformedOp); + + this.operations.slice(1).map(op => newBatchedOperation.add(op.transform(againstOp))); + + return newBatchedOperation; + } + + /** + * Checks if operation can be added to the batch + * + * Only text operations with the same type (Insert/Delete) on the same block and data key could be added + * + * @param op - operation to check + */ + public canAdd(op: Operation): boolean { + /** + * Can't add to batch insertion or deletion of several characters + */ + if (typeof op.data.payload === 'string' && op.data.payload?.length > 1) { + return false; + } + + const lastOp = this.operations[this.operations.length - 1]; + + if (lastOp === undefined) { + return true; + } + + if (!op.index.isTextIndex || !lastOp.index.isTextIndex) { + return false; + } + + if (op.type === OperationType.Modify || lastOp.type === OperationType.Modify) { + return false; + } + + if (op.type !== lastOp.type) { + return false; + } + + if (op.index.blockIndex !== lastOp.index.blockIndex || op.index.dataKey !== lastOp.index.dataKey) { + return false; + } + + return op.index.textRange![0] === lastOp.index.textRange![1] + 1; + } +} diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 8140489..cfc1b43 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -824,14 +824,14 @@ describe('CollaborationManager', () => { .addTextRange([0, 0]) .build(); const operation1 = new Operation(OperationType.Insert, index1, { - payload: 'te', + payload: 't', }, userId); const index2 = new IndexBuilder().from(index1) .addTextRange([1, 1]) .build(); const operation2 = new Operation(OperationType.Insert, index2, { - payload: 'st', + payload: 's', }, userId); collaborationManager.applyOperation(operation1); @@ -876,14 +876,14 @@ describe('CollaborationManager', () => { .addTextRange([0, 0]) .build(); const operation1 = new Operation(OperationType.Insert, index1, { - payload: 'te', + payload: 't', }, userId); const index2 = new IndexBuilder().from(index1) .addTextRange([1, 1]) .build(); const operation2 = new Operation(OperationType.Insert, index2, { - payload: 'st', + payload: 's', }, userId); collaborationManager.applyOperation(operation1); @@ -900,7 +900,7 @@ describe('CollaborationManager', () => { data: { text: { $t: 't', - value: 'test', + value: 'ts', fragments: [], }, }, @@ -945,4 +945,126 @@ describe('CollaborationManager', () => { properties: {}, }); }); + + describe('remote operations', () => { + it('should transform current batch when remote operation arrives', () => { + const model = new EditorJSModel(userId, { identifier: documentId }); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: '', + $t: 't', + }, + }, + } ], + }); + + const collaborationManager = new CollaborationManager(config as Required, model); + + // Create local operation + const localIndex = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 4]) + .build(); + + const localOp = new Operation(OperationType.Insert, localIndex, { + payload: 'test', + }, userId); + + collaborationManager.applyOperation(localOp); + + // Apply remote operation + const remoteIndex = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); + + const remoteOp = new Operation(OperationType.Insert, remoteIndex, { + payload: 'hello', + }, 'other-user'); + + collaborationManager.applyOperation(remoteOp); + + // Verify the operations were transformed correctly + expect(model.serialized).toStrictEqual({ + identifier: documentId, + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'hellotest', + fragments: [], + }, + }, + } ], + properties: {}, + }); + }); + + it('should clear current batch if not transformable with remote operation', () => { + const model = new EditorJSModel(userId, { identifier: documentId }); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: 'initial', + $t: 't', + }, + }, + } ], + }); + + const collaborationManager = new CollaborationManager(config as Required, model); + + // Create local delete operation + const localIndex = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 0]) + .build(); + + const localOp = new Operation(OperationType.Insert, localIndex, { + payload: 'initial', + }, userId); + + collaborationManager.applyOperation(localOp); + + // Apply conflicting remote operation + const remoteIndex = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 7]) + .build(); + + const remoteOp = new Operation(OperationType.Delete, remoteIndex, { + payload: 'initial', + }, 'other-user'); + + collaborationManager.applyOperation(remoteOp); + + // Verify the current batch was cleared by checking undo doesn't restore text + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + identifier: documentId, + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: '', + fragments: [], + }, + }, + } ], + properties: {}, + }); + }); + }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 7cc8f57..b0d918e 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -10,10 +10,12 @@ import { } from '@editorjs/model'; import type { CoreConfig } from '@editorjs/sdk'; import { OTClient } from './client/index.js'; -import { OperationsBatch } from './OperationsBatch.js'; +import { BatchedOperation } from './BatchedOperation.js'; import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; +const DEBOUCE_TIMEOUT = 500; + /** * CollaborationManager listens to EditorJSModel events and applies operations */ @@ -36,7 +38,7 @@ export class CollaborationManager { /** * Current operations batch */ - #currentBatch: OperationsBatch | null = null; + #currentBatch: BatchedOperation | null = null; /** * Editor's config @@ -48,6 +50,11 @@ export class CollaborationManager { */ #client: OTClient | null = null; + /** + * Debounce timer to move current batch to undo stack after a delay + */ + #debounceTimer?: ReturnType; + /** * Creates an instance of CollaborationManager * @@ -91,7 +98,7 @@ export class CollaborationManager { * Undo last operation in the local stack */ public undo(): void { - this.#currentBatch?.terminate(); + this.#moveBatchToUndo(); const operation = this.#undoRedoManager.undo(); @@ -99,7 +106,7 @@ export class CollaborationManager { return; } - // Disable event handling + // Disable handling this.#shouldHandleEvents = false; this.applyOperation(operation); @@ -112,7 +119,7 @@ export class CollaborationManager { * Redo last undone operation in the local stack */ public redo(): void { - this.#currentBatch?.terminate(); + this.#moveBatchToUndo(); const operation = this.#undoRedoManager.redo(); @@ -134,7 +141,20 @@ export class CollaborationManager { * * @param operation - operation to apply */ - public applyOperation(operation: Operation): void { + public applyOperation(operation: Operation | BatchedOperation): void { + /** + * If operation is a batcher operation, apply all operations in the batch + */ + if (operation instanceof BatchedOperation) { + operation.operations.forEach(op => this.applyOperation(op)); + + return; + } + + if (operation.type === OperationType.Neutral) { + return; + } + switch (operation.type) { case OperationType.Insert: this.#model.insertData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); @@ -159,13 +179,8 @@ export class CollaborationManager { * @param e - event to handle */ #handleEvent(e: ModelEvents): void { - if (!this.#shouldHandleEvents) { - return; - } - let operation: Operation | null = null; - /** * @todo add all model events */ @@ -212,34 +227,75 @@ export class CollaborationManager { return; } + /** + * If operation is local, send it to the server + */ if (operation.userId === this.#config.userId) { void this.#client?.send(operation); } else { + /** + * If operation is remote, transform undo/redo stacks + */ + this.#undoRedoManager.transformStacks(operation); + + /** + * If we got a new remote operation - transform current batch + * If batch is empty leave it as it is + */ + this.#currentBatch = this.#currentBatch?.transform(operation) ?? null; + return; } - const onBatchTermination = (batch: OperationsBatch, lastOp?: Operation): void => { - const effectiveOp = batch.getEffectiveOperation(); + if (!this.#shouldHandleEvents) { + return; + } - if (effectiveOp) { - this.#undoRedoManager.put(effectiveOp); - } + /** + * If there is no current batch, create a new one with current operation + */ + if (this.#currentBatch === null) { + this.#currentBatch = new BatchedOperation(operation); + this.#debounce(); - /** - * lastOp is the operation on which the batch was terminated. - * So if there is one, we need to create a new batch - * - * lastOp could be null if the batch was terminated by time out - */ - this.#currentBatch = lastOp === undefined ? null : new OperationsBatch(onBatchTermination, lastOp); - }; + return; + } - if (this.#currentBatch === null) { - this.#currentBatch = new OperationsBatch(onBatchTermination, operation); + /** + * If current operation could not be added to the batch, then terminate current batch and create a new one with current operation + */ + if (!this.#currentBatch.canAdd(operation)) { + this.#moveBatchToUndo(); + + this.#currentBatch = new BatchedOperation(operation); + this.#debounce(); return; } this.#currentBatch.add(operation); + this.#debounce(); + } + + /** + * Puts current batch to the undo stack and clears the batch + */ + #moveBatchToUndo(): void { + if (this.#currentBatch !== null) { + this.#undoRedoManager.put(this.#currentBatch); + + this.#currentBatch = null; + } + } + + /** + * Debouneces timer of #moveBatchToUndo method + */ + #debounce(): void { + clearTimeout(this.#debounceTimer); + + this.#debounceTimer = setTimeout(() => { + this.#moveBatchToUndo(); + }, DEBOUCE_TIMEOUT); } } diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts index f1f2455..50f21ea 100644 --- a/packages/collaboration-manager/src/Operation.spec.ts +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -62,15 +62,18 @@ describe('Operation', () => { expect(transformedOp).toEqual(receivedOp); }); - it('should not change operation if operation is not Block or Text operation', () => { + it('should throw Unsupppoted index type error if op is not Block or Text operation', () => { const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); const localOp = createOperation(OperationType.Insert, 0, 'def'); localOp.index.textRange = undefined; - const transformedOp = receivedOp.transform(localOp); - - expect(transformedOp).toEqual(receivedOp); + try { + receivedOp.transform(localOp); + } catch (e) { + expect(e).toBeInstanceOf(Error); + expect((e as Error).message).toContain('Unsupported index type'); + } }); it('should throw an error if unsupported operation type is provided', () => { @@ -190,7 +193,7 @@ describe('Operation', () => { it('should transform a received operation if it is at the same position as a local one', () => { const receivedOp = createOperation(OperationType.Modify, 3, 'abc'); - const localOp = createOperation(OperationType.Delete, 3, 'def'); + const localOp = createOperation(OperationType.Delete, 0, 'def'); const transformedOp = receivedOp.transform(localOp); expect(transformedOp.index.textRange).toEqual([0, 0]); @@ -248,7 +251,7 @@ describe('Operation', () => { expect(transformedOp.index.blockIndex).toEqual(0); }); - it('should adjust the block index if local op is a Block operation at the same index as a received one', () => { + it('should return Neutral operation if local op is a Block operation at the same index as a received one', () => { const receivedOp = createOperation(OperationType.Insert, 1, [ { name: 'paragraph', data: { text: 'abc' }, @@ -260,7 +263,7 @@ describe('Operation', () => { const transformedOp = receivedOp.transform(localOp); - expect(transformedOp.index.blockIndex).toEqual(0); + expect(transformedOp.type).toBe(OperationType.Neutral); }); }); }); diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index 45c939b..8cf13ef 100644 --- a/packages/collaboration-manager/src/Operation.ts +++ b/packages/collaboration-manager/src/Operation.ts @@ -1,4 +1,5 @@ import { IndexBuilder, type Index, type BlockNodeSerialized } from '@editorjs/model'; +import { OperationsTransformer } from './OperationsTransformer.js'; /** * Type of the operation @@ -6,7 +7,8 @@ import { IndexBuilder, type Index, type BlockNodeSerialized } from '@editorjs/mo export enum OperationType { Insert = 'insert', Delete = 'delete', - Modify = 'modify' + Modify = 'modify', + Neutral = 'neutral', } /** @@ -38,6 +40,13 @@ export interface ModifyOperationData = Record { */ export type OperationTypeToData = T extends OperationType.Modify ? ModifyOperationData - : InsertOrDeleteOperationData; + : T extends OperationType.Neutral + ? NeutralOperationData + : InsertOrDeleteOperationData; /** * Helper type to get invert operation type @@ -82,7 +93,9 @@ export type InvertedOperationType = T extends Operation ? OperationType.Delete : T extends OperationType.Delete ? OperationType.Insert - : OperationType.Modify; + : T extends OperationType.Neutral + ? OperationType.Neutral + : OperationType.Modify; /** @@ -92,7 +105,7 @@ export class Operation { /** * Operation type */ - public type: T; + public type: T | OperationType.Neutral; /** * Index in the document model tree @@ -114,6 +127,11 @@ export class Operation { */ public rev?: number; + /** + * Transformer for operations + */ + #transformer: OperationsTransformer = new OperationsTransformer(); + /** * Creates an instance of Operation * @@ -123,10 +141,10 @@ export class Operation { * @param userId - user identifier * @param rev - document revision */ - constructor(type: T, index: Index, data: OperationTypeToData, userId: string | number, rev?: number) { + constructor(type: T | OperationType.Neutral, index: Index, data: OperationTypeToData | OperationTypeToData, userId: string | number, rev?: number) { this.type = type; this.index = index; - this.data = data; + this.data = data as OperationTypeToData; this.userId = userId; this.rev = rev; } @@ -136,7 +154,7 @@ export class Operation { * * @param op - operation to copy */ - public static from(op: Operation): Operation; + public static from(op: Operation | Operation): Operation; /** * Creates an operation from another operation or serialized operation * @@ -198,64 +216,12 @@ export class Operation { /** * Transforms the operation against another operation + * If operation is not transformable returns null * * @param againstOp - operation to transform against */ - public transform(againstOp: Operation): Operation { - /** - * Do not transform operations if they are on different documents - */ - if (this.index.documentId !== againstOp.index.documentId) { - return this; - } - - /** - * Do not transform if the againstOp index is greater or if againstOp is Modify op - */ - if (!this.#shouldTransform(againstOp.index) || againstOp.type === OperationType.Modify) { - return this; - } - - const newIndexBuilder = new IndexBuilder().from(this.index); - - switch (againstOp.type) { - case OperationType.Insert: { - const payload = (againstOp as Operation).data.payload; - - if (againstOp.index.isBlockIndex) { - newIndexBuilder.addBlockIndex(this.index.blockIndex! + payload.length); - - break; - } - - newIndexBuilder.addTextRange([this.index.textRange![0] + payload.length, this.index.textRange![1] + payload.length]); - - break; - } - - case OperationType.Delete: { - const payload = (againstOp as Operation).data.payload; - - if (againstOp.index.isBlockIndex) { - newIndexBuilder.addBlockIndex(this.index.blockIndex! - payload.length); - - break; - } - - newIndexBuilder.addTextRange([this.index.textRange![0] - payload.length, this.index.textRange![1] - payload.length]); - - break; - } - - default: - throw new Error('Unsupported operation type'); - } - - const operation = Operation.from(this); - - operation.index = newIndexBuilder.build(); - - return operation; + public transform(againstOp: Operation | Operation): Operation { + return this.#transformer.transform(this, againstOp); } /** @@ -270,23 +236,4 @@ export class Operation { rev: this.rev!, }; } - - /** - * Checks if operation needs to be transformed: - * 1. If relative operation (againstOp) happened in the block before or at the same index of the Block of _this_ operation - * 2. If relative operation happened in the same block and same data key and before the text range of _this_ operation - * - * @param indexToCompare - index of a relative operation - */ - #shouldTransform(indexToCompare: Index): boolean { - if (indexToCompare.isBlockIndex && this.index.blockIndex !== undefined) { - return indexToCompare.blockIndex! <= this.index.blockIndex; - } - - if (indexToCompare.isTextIndex && this.index.isTextIndex) { - return indexToCompare.dataKey === this.index.dataKey && indexToCompare.textRange![0] <= this.index.textRange![0]; - } - - return false; - } } diff --git a/packages/collaboration-manager/src/OperationsBatch.spec.ts b/packages/collaboration-manager/src/OperationsBatch.spec.ts deleted file mode 100644 index 7902eea..0000000 --- a/packages/collaboration-manager/src/OperationsBatch.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { createDataKey, IndexBuilder } from '@editorjs/model'; -import { OperationsBatch } from './OperationsBatch.js'; -import { Operation, OperationType } from './Operation.js'; -import { jest } from '@jest/globals'; - -const templateIndex = new IndexBuilder() - .addBlockIndex(0) - .addDataKey(createDataKey('key')) - .addTextRange([0, 0]) - .build(); - -const userId = 'user'; - -describe('Batch', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - it('should add Insert operation to batch', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Insert, - new IndexBuilder().from(templateIndex) - .addTextRange([1, 1]) - .build(), - { payload: 'b' }, - userId - ); - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - const effectiveOp = batch.getEffectiveOperation(); - - expect(effectiveOp).toEqual({ - type: OperationType.Insert, - index: new IndexBuilder() - .from(templateIndex) - .addTextRange([0, 1]) - .build(), - data: { payload: 'ab' }, - rev: undefined, - userId, - }); - }); - - it('should add Delete operation to batch', () => { - const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Delete, - new IndexBuilder().from(templateIndex) - .addTextRange([1, 1]) - .build(), - { payload: 'b' }, - userId - ); - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - const effectiveOp = batch.getEffectiveOperation(); - - expect(effectiveOp).toEqual({ - type: OperationType.Delete, - index: new IndexBuilder() - .from(templateIndex) - .addTextRange([0, 1]) - .build(), - data: { payload: 'ab' }, - rev: undefined, - userId, - }); - }); - - it('should terminate the batch if the new operation is not text operation', () => { - const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Delete, - new IndexBuilder().from(templateIndex) - .addDataKey(undefined) - .addTextRange(undefined) - .build(), - { - payload: [ - { - name: 'paragraph', - data: { text: '' }, - }, - ], - }, - userId - ); - - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if operation in the batch is not text operation', () => { - const op1 = new Operation( - OperationType.Delete, - new IndexBuilder().from(templateIndex) - .addDataKey(undefined) - .addTextRange(undefined) - .build(), - { - payload: [ - { - name: 'paragraph', - data: { text: '' }, - }, - ], - }, - userId - ); - const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); - - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if operation in the batch is Modify operation', () => { - const op1 = new Operation( - OperationType.Modify, - new IndexBuilder().from(templateIndex) - .build(), - { - payload: { - tool: 'bold', - }, - prevPayload: { - tool: 'bold', - }, - }, - userId - ); - const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); - - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if the new operation is Modify operation', () => { - const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Modify, - new IndexBuilder().from(templateIndex) - .build(), - { - payload: { - tool: 'bold', - }, - prevPayload: { - tool: 'bold', - }, - }, - userId - ); - - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if operations are of different type', () => { - const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Insert, - new IndexBuilder().from(templateIndex) - .addTextRange([1, 1]) - .build(), - { payload: 'b' }, - userId - ); - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if operations block indexes are not the same', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Insert, - new IndexBuilder().from(templateIndex) - .addBlockIndex(1) - .addTextRange([1, 1]) - .build(), - { payload: 'b' }, - userId - ); - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if operations data keys are not the same', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Insert, - new IndexBuilder().from(templateIndex) - .addDataKey(createDataKey('differentKey')) - .addTextRange([1, 1]) - .build(), - { payload: 'b' }, - userId - ); - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if operations index ranges are not adjacent', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); - const op2 = new Operation( - OperationType.Insert, - new IndexBuilder().from(templateIndex) - .addTextRange([2, 2]) - .build(), - { payload: 'b' }, - userId - ); - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - batch.add(op2); - - expect(onTimeout).toBeCalledWith(batch, op2); - }); - - it('should terminate the batch if timeout is exceeded', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); - - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - jest.advanceTimersByTime(1000); - - expect(onTimeout).toBeCalledWith(batch, undefined); - }); - - it('should return null if there\'s no operations as effective operation in the batch', () => { - const onTimeout = jest.fn(); - const batch = new OperationsBatch(onTimeout); - - expect(batch.getEffectiveOperation()).toBeNull(); - }); - - it('should return the only operation in the batch as effective operation', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); - - const onTimeout = jest.fn(); - - const batch = new OperationsBatch(onTimeout, op1); - - expect(batch.getEffectiveOperation()).toEqual(op1); - }); -}); diff --git a/packages/collaboration-manager/src/OperationsBatch.ts b/packages/collaboration-manager/src/OperationsBatch.ts deleted file mode 100644 index 51661e5..0000000 --- a/packages/collaboration-manager/src/OperationsBatch.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { IndexBuilder, type TextRange } from '@editorjs/model'; -import { Operation, OperationType } from './Operation.js'; - -/** - * Batch debounce time - */ -const DEBOUNCE_TIMEOUT = 500; - -/** - * Batch termination callback - * - * @param batch - terminated batch - * @param [lastOperation] - operation on which the batch was terminated - */ -type OnBatchTermination = (batch: OperationsBatch, lastOperation?: Operation) => void; - -/** - * Class to batch Text operations (maybe others in the future) for Undo/Redo purposes - * - * Operations are batched on timeout basis or if batch is terminated from the outside - */ -export class OperationsBatch { - /** - * Array of operations to batch - * - * @private - */ - #operations: Operation[] = []; - - /** - * Termination callback - */ - #onTermination: OnBatchTermination; - - /** - * Termination timeout - */ - #debounceTimer?: ReturnType; - - /** - * Batch constructor function - * - * @param onTermination - termination callback - * @param firstOperation - first operation to add - */ - constructor(onTermination: OnBatchTermination = () => {}, firstOperation?: Operation) { - this.#onTermination = onTermination; - - if (firstOperation !== undefined) { - this.add(firstOperation); - } - } - - /** - * Adds an operation to the batch - * - * @param op - operation to add - */ - public add(op: Operation): void { - if (!this.#canAdd(op)) { - this.terminate(op); - - return; - } - - this.#operations.push(op); - - clearTimeout(this.#debounceTimer); - this.#debounceTimer = setTimeout(() => this.terminate(), DEBOUNCE_TIMEOUT); - } - - - /** - * Returns and effective operations for all the operations in the batch - */ - public getEffectiveOperation(): Operation | null { - if (this.#operations.length === 0) { - return null; - } - - if (this.#operations.length === 1) { - return this.#operations[0]; - } - - const type = this.#operations[0].type; - const index = this.#operations[0].index; - - const range: TextRange = [ - this.#operations[0].index.textRange![0], - this.#operations[this.#operations.length - 1].index.textRange![1], - ]; - const payload = this.#operations.reduce((text, operation) => text + operation.data.payload, ''); - - return new Operation( - type, - new IndexBuilder().from(index) - .addTextRange(range) - .build(), - { payload }, - this.#operations[0].userId - ); - } - - /** - * Terminates the batch, passes operation on which batch was terminated to the callback - * - * @param lastOp - operation on which the batch is terminated - */ - public terminate(lastOp?: Operation): void { - clearTimeout(this.#debounceTimer); - - this.#onTermination(this, lastOp); - } - - /** - * Checks if operation can be added to the batch - * - * Only text operations with the same type (Insert/Delete) on the same block and data key could be added - * - * @param op - operation to check - */ - #canAdd(op: Operation): boolean { - const lastOp = this.#operations[this.#operations.length - 1]; - - if (lastOp === undefined) { - return true; - } - - if (!op.index.isTextIndex || !lastOp.index.isTextIndex) { - return false; - } - - if (op.type === OperationType.Modify || lastOp.type === OperationType.Modify) { - return false; - } - - if (op.type !== lastOp.type) { - return false; - } - - if (op.index.blockIndex !== lastOp.index.blockIndex || op.index.dataKey !== lastOp.index.dataKey) { - return false; - } - - return op.index.textRange![0] === lastOp.index.textRange![1] + 1; - } -} diff --git a/packages/collaboration-manager/src/OperationsTransformer.spec.ts b/packages/collaboration-manager/src/OperationsTransformer.spec.ts new file mode 100644 index 0000000..55b4154 --- /dev/null +++ b/packages/collaboration-manager/src/OperationsTransformer.spec.ts @@ -0,0 +1,288 @@ +import type { DocumentId } from '@editorjs/model'; +import { createDataKey, IndexBuilder } from '@editorjs/model'; +import { Operation, OperationType } from './Operation.js'; +import { OperationsTransformer } from './OperationsTransformer.js'; + +/* eslint-disable @typescript-eslint/no-magic-numbers */ +describe('OperationsTransformer', () => { + let transformer: OperationsTransformer; + + beforeEach(() => { + transformer = new OperationsTransformer(); + }); + + describe('transform', () => { + it('should not transform operations on different documents', () => { + const operation = new Operation( + OperationType.Insert, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(0) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Insert, + new IndexBuilder().addDocumentId('doc2' as DocumentId) + .addBlockIndex(0) + .build(), + { payload: 'test' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result).toEqual(operation); + }); + + describe('Block operations transformation', () => { + describe('Against block operations', () => { + it('should increase block index when transforming against Insert operation', () => { + const operation = new Operation( + OperationType.Insert, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(2) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Insert, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .build(), + { payload: 'test' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.index.blockIndex).toBe(3); + }); + + it('should decrease block index when transforming against Delete operation before current block', () => { + const operation = new Operation( + OperationType.Insert, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(2) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Delete, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .build(), + { payload: 'test' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.index.blockIndex).toBe(1); + }); + + it('should return Neutral operation when transforming against Delete operation of the same block', () => { + const operation = new Operation( + OperationType.Modify, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Delete, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .build(), + { payload: 'test' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.type).toBe(OperationType.Neutral); + }); + }); + + describe('Against text operations', () => { + it('should not transform block operation against text operation', () => { + const operation = new Operation( + OperationType.Insert, + new IndexBuilder().addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Insert, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([0, 1]) + .build(), + { payload: 'a' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result).toEqual(operation); + }); + }); + }); + + describe('Text operations transformation', () => { + describe('Against text Insert operations', () => { + it('should shift right text range when Insert is before current operation', () => { + const operation = new Operation( + OperationType.Insert, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([5, 8]) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Insert, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([2, 2]) + .build(), + { payload: 'abc' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.index.textRange).toEqual([8, 11]); + }); + + it('should extend text range when Insert is inside current operation range', () => { + const operation = new Operation( + OperationType.Modify, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([2, 8]) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Insert, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([4, 4]) + .build(), + { payload: 'abc' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.index.textRange).toEqual([2, 11]); + }); + }); + + describe('Against text Delete operations', () => { + it('should shift text range left when Delete is before current operation', () => { + const operation = new Operation( + OperationType.Insert, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([8, 10]) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Delete, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([2, 5]) + .build(), + { payload: 'abc' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.index.textRange).toEqual([5, 7]); + }); + + it('should return Neutral operation when Delete fully covers current operation', () => { + const operation = new Operation( + OperationType.Modify, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([3, 5]) + .build(), + { payload: 'test' }, + 'user1', + 1 + ); + + const againstOp = new Operation( + OperationType.Delete, + new IndexBuilder() + .addDocumentId('doc1' as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([2, 8]) + .build(), + { payload: 'abcdef' }, + 'user2', + 1 + ); + + const result = transformer.transform(operation, againstOp); + + expect(result.type).toBe(OperationType.Neutral); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/collaboration-manager/src/OperationsTransformer.ts b/packages/collaboration-manager/src/OperationsTransformer.ts new file mode 100644 index 0000000..46ba29a --- /dev/null +++ b/packages/collaboration-manager/src/OperationsTransformer.ts @@ -0,0 +1,328 @@ +import { IndexBuilder } from '@editorjs/model'; +import { Operation, OperationType } from './Operation.js'; +import { getRangesIntersectionType, RangeIntersectionType } from './utils/getRangesIntersectionType.js'; + +/** + * Class that transforms operation against another operation + */ +export class OperationsTransformer { + /** + * Method that transforms operation against another operation + * + * @param operation - operation to be transformed + * @param againstOp - operation against which the current operation should be transformed + * @returns {Operation} new operation + */ + public transform(operation: Operation, againstOp: Operation): Operation | Operation { + /** + * Do not transform operations if they are on different documents + */ + if (operation.index.documentId !== againstOp.index.documentId) { + return Operation.from(operation); + } + + /** + * Throw unsupported operation type error if operation type is not supported + */ + if (!Object.values(OperationType).includes(againstOp.type) || !Object.values(OperationType).includes(operation.type)) { + throw new Error('Unsupported operation type'); + } + + return this.#applyTransformation(operation, againstOp); + } + + /** + * Method that returns new operation based on the type of againstOp index + * Cases: + * 1. Against operation is a block operation and current operation is also a block operation + * - check if againstOp affects current operation, update operation's block index + * + * 2. Against operation is a block operation and current operation is a text operation + * - same as above, check if againstOp affects current operation and update operation's block index + * + * 3. Against operation is a text operation and current operation is a block operation + * - text operation does not afftect block operation - so return copy of current operation + * + * 4. Against operation is a text operation and current operation is also a text operation + * - check if againstOp affects current operation and update operation's text index + * + * @param operation - operation to be transformed + * @param againstOp - operation against which the current operation should be transformed + * @returns {Operation} new operation + */ + #applyTransformation(operation: Operation, againstOp: Operation): Operation | Operation { + const againstIndex = againstOp.index; + + switch (true) { + case (againstIndex.isBlockIndex): + return this.#transformAgainstBlockOperation(operation, againstOp); + + case (againstIndex.isTextIndex): + return this.#transformAgainstTextOperation(operation, againstOp); + + /** + * @todo Cover all index types + */ + default: + throw new Error('Unsupported index type'); + } + } + + /** + * Method that transforms operation against block operation + * + * Cases: + * 1. Against operation is an Insert operation + * - Increase block index of the current operation + * + * 2. Against operation is a Delete operation + * - Against operation deleted a block before the current operation + * - Decrease block index of the current operation + * - Against operation deleted exactly the block of the current operation + * - Return Neutral operation + * + * 3. Against operation is a Modify or Neutral operation + * - Modify and Neutral operations do not affect any operations so return copy of the current operation + * + * @param operation - Operation to be transformed + * @param againstOp - Operation against which the current operation should be transformed + * @returns {Operation} new operation + */ + #transformAgainstBlockOperation(operation: Operation, againstOp: Operation): Operation | Operation { + const newIndexBuilder = new IndexBuilder().from(operation.index); + + /** + * If current operation has no block index, return copy of the current operation + */ + if (operation.index.blockIndex === undefined) { + return Operation.from(operation); + } + + /** + * Check that againstOp affects current operation + */ + if (againstOp.index.blockIndex! > operation.index.blockIndex!) { + return Operation.from(operation); + } + + /** + * Update the index of the current operation + */ + switch (againstOp.type) { + /** + * Cover case 1 + */ + case OperationType.Insert: + newIndexBuilder.addBlockIndex(operation.index.blockIndex! + 1); + break; + + /** + * Cover case 2 + */ + case OperationType.Delete: + if (againstOp.index.blockIndex! >= operation.index.blockIndex!) { + return new Operation(OperationType.Neutral, newIndexBuilder.build(), { payload: [] }, operation.userId, operation.rev); + } + + newIndexBuilder.addBlockIndex(operation.index.blockIndex! - 1); + + break; + + /** + * Cover case 3 + */ + default: + return Operation.from(operation); + } + + /** + * Return new operation with the updated index + */ + const newOp = Operation.from(operation); + + newOp.index = newIndexBuilder.build(); + + return newOp; + } + + /** + * Method that transforms operation against text operation + * + * Cases: + * 1. Current operation is a block operation + * - Text opearation cant affect block operation so return copy of the current one + * + * 2. Current operation is a text operation + * - Against operation is Insert + * - Transform current operation against textInsert + * - Against operation is Delete + * - Check that againstOp affects current operation and transform against text operation + * - Against operation is Modify or Neutral + * - Modify and Neutral operations do not affect any of the text operations so return copy of the current operation + * + * @param operation - Operation to be transformed + * @param againstOp - Operation against which the current operation should be transformed + * @returns {Operation} new operation + */ + #transformAgainstTextOperation(operation: Operation, againstOp: Operation): Operation | Operation { + const index = operation.index; + const againstIndex = againstOp.index; + + /** + * Cover case 1 + */ + if (index.isBlockIndex) { + return Operation.from(operation); + } + + const sameInput = index.dataKey === againstIndex.dataKey; + const sameBlock = index.blockIndex === againstIndex.blockIndex; + + /** + * Check that againstOp affects current operation + */ + if (!sameInput || !sameBlock || againstIndex.textRange![0] > index.textRange![1]) { + return Operation.from(operation); + } + + switch (againstOp.type) { + case OperationType.Insert: + return this.#transformAgainstTextInsert(operation, againstOp); + + case OperationType.Delete: + return this.#transformAgainstTextDelete(operation, againstOp); + + default: + return Operation.from(operation); + } + } + + /** + * Method that transforms operation against text insert operation happened on the left side of the current operation + * + * Cases: + * 1. Against operation is fully on the left of the current operation + * - Move text range of the current operation to the right by amount of inserted characters + * + * 2. Against operation is inside of the current operation text range + * - Move right bound of the current operation to the right by amount of inserted characters to include the inserted text + * + * @param operation - Operation to be transformed + * @param againstOp - Operation against which the current operation should be transformed + * @returns {Operation} new operation + */ + #transformAgainstTextInsert(operation: Operation, againstOp: Operation): Operation | Operation { + const newIndexBuilder = new IndexBuilder().from(operation.index); + + const insertedLength = againstOp.data.payload!.length; + + const index = operation.index; + const againstIndex = againstOp.index; + + /** + * In this case, againstOp is insert operatioin, there would be only two possible intersections + * - None - inserted text is on the left side of the current operation + * - Includes - inserted text is inside of the current operation text range + */ + const intersectionType = getRangesIntersectionType(index.textRange!, againstIndex.textRange!); + + switch (intersectionType) { + case (RangeIntersectionType.None): + case (RangeIntersectionType.Left): + newIndexBuilder.addTextRange([index.textRange![0] + insertedLength, index.textRange![1] + insertedLength]); + break; + + case (RangeIntersectionType.Includes): + newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] + insertedLength]); + break; + } + + /** + * Return new operation with the updated index + */ + const newOp = Operation.from(operation); + + newOp.index = newIndexBuilder.build(); + + return newOp; + } + + /** + * Method that transforms operation against text delete operation + * + * Cases: + * 1. Delete range is fully on the left of the current operation + * - Move text range of the current operation to the left by amount of deleted characters + * + * 2. Delete range covers part of the current operation + * - Deleted left side of the current operation + * - Move left bound of the current operation to the start of the against Delete operation + * - Move right bound of the current operation to the left by (amount of deleted characters - amount of characters in the current operation that were deleted) + * - Deleted right side of the current operation + * - Move right bound of the current operation to the left by amount of deleted intersection + * + * 3. Delete range is inside of the current operation text range + * - Move right bound of the current operation to the left by amount of deleted characters + * + * 4. Delete range fully covers the current operation text rannge + * - Return Neutral operation + * + * @param operation - Operation to be transformed + * @param againstOp - Operation against which the current operation should be transformed + * @returns {Operation} new operation + */ + #transformAgainstTextDelete(operation: Operation, againstOp: Operation): Operation | Operation { + const newIndexBuilder = new IndexBuilder().from(operation.index); + const deletedAmount = againstOp.data.payload!.length; + + const index = operation.index; + const againstIndex = againstOp.index; + + const intersectionType = getRangesIntersectionType(index.textRange!, againstIndex.textRange!); + + switch (intersectionType) { + /** + * Cover case 1 + */ + case (RangeIntersectionType.None): + newIndexBuilder.addTextRange([index.textRange![0] - deletedAmount, index.textRange![1] - deletedAmount]); + break; + + /** + * Cover case 2.1 + */ + case (RangeIntersectionType.Left): + newIndexBuilder.addTextRange([againstIndex.textRange![0], index.textRange![1] - deletedAmount]); + break; + + /** + * Cover case 2.2 + */ + case (RangeIntersectionType.Right): + newIndexBuilder.addTextRange([index.textRange![0], againstIndex.textRange![0]]); + break; + + /** + * Cover case 3 + */ + case (RangeIntersectionType.Includes): + newIndexBuilder.addTextRange([index.textRange![0], index.textRange![1] - deletedAmount]); + break; + + /** + * Cover case 4 + */ + case (RangeIntersectionType.Included): + return new Operation(OperationType.Neutral, newIndexBuilder.build(), { payload: [] }, operation.userId, operation.rev); + } + + /** + * Return new operation with updated index + */ + const newOp = Operation.from(operation); + + newOp.index = newIndexBuilder.build(); + + return newOp; + } +} \ No newline at end of file diff --git a/packages/collaboration-manager/src/UndoRedoManager.spec.ts b/packages/collaboration-manager/src/UndoRedoManager.spec.ts index ba95322..bc69721 100644 --- a/packages/collaboration-manager/src/UndoRedoManager.spec.ts +++ b/packages/collaboration-manager/src/UndoRedoManager.spec.ts @@ -2,9 +2,33 @@ import { IndexBuilder } from '@editorjs/model'; import { describe } from '@jest/globals'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; +import { jest } from '@jest/globals'; const userId = 'user'; +/** + * Helper function to create test operations + * + * @param index - block index of the operation + * @param text - text of the operation + * @param type - type of the operation + */ +function createOperation(index: number, text: string, type: OperationType = OperationType.Insert): Operation { + return new Operation( + type, + new IndexBuilder() + .addBlockIndex(index) + .build(), + { + payload: [ { + name: 'paragraph', + data: { text }, + } ], + }, + userId + ); +} + describe('UndoRedoManager', () => { it('should return inverted operation on undo', () => { const manager = new UndoRedoManager(); @@ -108,4 +132,69 @@ describe('UndoRedoManager', () => { expect(result).toBeUndefined(); }); + + describe('transform operations', () => { + it('should transform undo stack operations', () => { + const manager = new UndoRedoManager(); + const op1 = createOperation(0, 'first'); + const op2 = createOperation(1, 'second'); + + // Put operations in undo stack + manager.put(op1); + manager.put(op2); + + // Create operation that will affect the stack + const transformingOp = createOperation(0, 'transform'); + + // Mock transform method + const transformSpy = jest.spyOn(op2, 'transform').mockReturnValue(createOperation(2, 'transformed')); + + manager.transformStacks(transformingOp); + + expect(transformSpy).toHaveBeenCalledWith(transformingOp); + }); + + it('should handle empty stacks during transformation', () => { + const manager = new UndoRedoManager(); + const transformingOp = createOperation(0, 'transform'); + + // Should not throw when transforming empty stacks + expect(() => { + manager.transformStacks(transformingOp); + }).not.toThrow(); + }); + + it('should maintain correct operation order after transforming both stacks', () => { + const manager = new UndoRedoManager(); + const op1 = createOperation(0, 'first'); + const op2 = createOperation(1, 'second'); + const op3 = createOperation(2, 'third'); + + // Setup initial state + manager.put(op1); + manager.put(op2); + manager.put(op3); + + // Move op3 and op2 to redo stack + manager.undo(); // undo op3 + manager.undo(); // undo op2 + + const transformingOp = createOperation(0, 'transform'); + + // Mock transforms to return operations with shifted indices + /* eslint-disable @typescript-eslint/no-magic-numbers */ + jest.spyOn(op1, 'transform').mockReturnValue(createOperation(1, 'transformed-1')); + jest.spyOn(op2, 'transform').mockReturnValue(createOperation(2, 'transformed-2')); + jest.spyOn(op3, 'transform').mockReturnValue(createOperation(3, 'transformed-3')); + /* eslint-enable @typescript-eslint/no-magic-numbers */ + manager.transformStacks(transformingOp); + + // Verify operations can be redone in correct order + const redoOp1 = manager.redo(); + const redoOp2 = manager.redo(); + + expect(redoOp1?.index.blockIndex?.toString()).toBe('2'); // transformed op2 + expect(redoOp2?.index.blockIndex?.toString()).toBe('3'); // transformed op3 + }); + }); }); diff --git a/packages/collaboration-manager/src/UndoRedoManager.ts b/packages/collaboration-manager/src/UndoRedoManager.ts index 08c7cba..9315780 100644 --- a/packages/collaboration-manager/src/UndoRedoManager.ts +++ b/packages/collaboration-manager/src/UndoRedoManager.ts @@ -43,6 +43,7 @@ export class UndoRedoManager { const invertedOperation = operation.inverse(); + this.#undoStack.push(invertedOperation); return invertedOperation; @@ -57,4 +58,25 @@ export class UndoRedoManager { this.#undoStack.push(operation); this.#redoStack = []; } + + /** + * Transforms undo and redo stacks + * + * @param operation - operation to transform against + */ + public transformStacks(operation: Operation): void { + this.#undoStack = this.transformStack(operation, this.#undoStack); + this.#redoStack = this.transformStack(operation, this.#redoStack); + } + + /** + * Transforms passed operations stack against the operation + * + * @param operation - operation to transform against + * @param stack - stack to transform + * @returns {Operation[]} - new transformed list of operations + */ + private transformStack(operation: Operation, stack: Operation[]): Operation[] { + return stack.map((op: Operation) => op.transform(operation)); + } } diff --git a/packages/collaboration-manager/src/utils/getRangesIntersectionType.ts b/packages/collaboration-manager/src/utils/getRangesIntersectionType.ts new file mode 100644 index 0000000..26425ba --- /dev/null +++ b/packages/collaboration-manager/src/utils/getRangesIntersectionType.ts @@ -0,0 +1,56 @@ +import type { TextRange } from '@editorjs/model'; + +/** + * Represents the type of intersection between two text ranges. + */ +export enum RangeIntersectionType { + Left = 'left', + Right = 'right', + Includes = 'includes', + Included = 'included', + None = 'none', +} + +/** + * Returns the type of intersection between two text ranges + * + * @param range - Range to check + * @param rangeToCompare - Range to compare with + * @returns {RangeIntersectionType} Type of intersection + */ +export function getRangesIntersectionType(range: TextRange, rangeToCompare: TextRange): RangeIntersectionType { + const [start, end] = range; + const [startToCompare, endToCompare] = rangeToCompare; + + /** + * Range is fully on the left or right of the range to compare + */ + if (end < startToCompare || start > endToCompare) { + return RangeIntersectionType.None; + } + + /** + * Range includes the range to compare + * If two ranges are the same, intersection type is "includes" + */ + if (start <= startToCompare && end >= endToCompare && start != end) { + return RangeIntersectionType.Includes; + } + + /** + * Range is included in the range to compare + */ + if (start > startToCompare && end < endToCompare) { + return RangeIntersectionType.Included; + } + + /** + * Right side of the range intersects with left side of the range to compare + * Cases with includes and included are handled before + */ + if (end > startToCompare && end < endToCompare) { + return RangeIntersectionType.Right; + } + + return RangeIntersectionType.Left; +} \ No newline at end of file diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index b1aca41..037fc87 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -29,7 +29,8 @@ onMounted(() => { holder: document.getElementById('editorjs') as HTMLElement, userId: userId, documentId: 'test', - // collaborationServer: 'ws://localhost:8080', + // collaborationServer: 'wss://lirili-larila.codex.so/', + collaborationServer: 'ws://localhost:8080', data: { blocks: [ { diff --git a/packages/ui/src/Blocks/Blocks.ts b/packages/ui/src/Blocks/Blocks.ts index 5b1a53c..9e4dd71 100644 --- a/packages/ui/src/Blocks/Blocks.ts +++ b/packages/ui/src/Blocks/Blocks.ts @@ -70,10 +70,14 @@ export class BlocksUI implements EditorjsPlugin { if (e.shiftKey) { this.#eventBus.dispatchEvent(new Event('core:redo')); + e.preventDefault(); + return; } this.#eventBus.dispatchEvent(new Event('core:undo')); + + e.preventDefault(); }); }