diff --git a/.github/workflows/collaboration-manager.yml b/.github/workflows/collaboration-manager.yml index b40eca5..7fec28a 100644 --- a/.github/workflows/collaboration-manager.yml +++ b/.github/workflows/collaboration-manager.yml @@ -8,6 +8,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + - run: yarn + + - name: Build the package + uses: ./.github/actions/build + with: + package-name: '@editorjs/model' + - name: Run ESLint check uses: ./.github/actions/lint with: diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts new file mode 100644 index 0000000..82f5d6a --- /dev/null +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import type { DataKey, DocumentIndex } from '@editorjs/model'; +import { IndexBuilder } from '@editorjs/model'; +import { Operation, OperationType } from './Operation.js'; + +describe('Operation', () => { + const createOperation = (type: OperationType, startIndex: number, value: string): Operation => { + return new Operation( + type, + new IndexBuilder() + .addBlockIndex(0) + .addDataKey('text' as DataKey) + .addTextRange([startIndex, startIndex]) + .build(), + { + prevValue: type === OperationType.Delete ? value : '', + newValue: type === OperationType.Insert ? value : '', + } + ); + }; + + describe('Insert vs Insert', () => { + test('Should not change a received operation if it is before a local one', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 3, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + test('Should adjust an index for a received operation if it is after a local one', () => { + const receivedOp = createOperation(OperationType.Insert, 3, 'def'); + const localOp = createOperation(OperationType.Insert, 0, 'abc'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([6, 6]); + }); + + test('Should not change a received operation if it is at the same position as a local one', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + }); + + describe('Delete vs Delete', () => { + test('Should not change a received operation if it is before a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 0, 'abc'); + const localOp = createOperation(OperationType.Delete, 3, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + test('Should adjust an index for a received operation if it is after a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 3, 'def'); + const localOp = createOperation(OperationType.Delete, 0, 'abc'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([0, 0]); + }); + + test('Should adjust an index for a received operation if it is at the same position as a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 0, 'abc'); + const localOp = createOperation(OperationType.Delete, 0, 'abc'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([0, 0]); + }); + }); + + describe('Insert vs Delete', () => { + test('Should not change a received operation if it is before a local one', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Delete, 3, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + test('Should adjust an index for a received operation if it is after a local one', () => { + const receivedOp = createOperation(OperationType.Insert, 6, 'ghi'); + const localOp = createOperation(OperationType.Delete, 0, 'abc'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([3, 3]); + }); + + test('Should not change a received operation if it is at the same position as a local one', () => { + const receivedOp = createOperation(OperationType.Insert, 3, 'def'); + const localOp = createOperation(OperationType.Delete, 3, 'ghi'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + }); + + describe('Delete vs Insert', () => { + test('Should not change a received operation if it is before a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 3, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + test('Should adjust an index for a received operation if it is after a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 6, 'ghi'); + const localOp = createOperation(OperationType.Insert, 0, 'abc'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([9, 9]); + }); + + test('Should adjust an index for a received operation if it is at the same position as a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 3, 'def'); + const localOp = createOperation(OperationType.Insert, 3, 'ghi'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([6, 6]); + }); + }); + + describe('Edge cases', () => { + test('Should not change operation if document ids are different', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, 'def'); + + localOp.index.documentId = 'document2' as DocumentIndex; + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + test('Should not change operation if blocks are different', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, 'def'); + + localOp.index.blockIndex = 1; + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + test('Should throw an error if unsupported index type is provided', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'def'); + + receivedOp.index.textRange = undefined; + const localOp = createOperation(OperationType.Insert, 0, 'def'); + + expect(() => receivedOp.transform(localOp)).toThrow('Unsupported index'); + }); + + test('Should throw an error if unsupported operation type is provided', () => { + const receivedOp = createOperation(OperationType.Modify, 0, 'def'); + const localOp = createOperation(OperationType.Insert, 0, 'def'); + + expect(() => receivedOp.transform(localOp)).toThrow('Unsupported operation type'); + }); + }); +}); diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index 79ea904..d191156 100644 --- a/packages/collaboration-manager/src/Operation.ts +++ b/packages/collaboration-manager/src/Operation.ts @@ -1,4 +1,4 @@ -import type { Index } from '@editorjs/model'; +import { IndexBuilder, type Index } from '@editorjs/model'; /** * Type of the operation @@ -56,4 +56,130 @@ export class Operation { this.index = index; this.data = data; } + + /** + * Makes an inverse operation + */ + public inverse(): Operation { + const index = this.index; + + switch (this.type) { + case OperationType.Insert: + + const textRange = index.textRange; + + if (textRange == undefined) { + throw new Error('Unsupported index'); + } + + const [ textRangeStart ] = textRange; + + const newIndex = new IndexBuilder() + .from(index) + .addTextRange([textRangeStart, textRangeStart + this.data.newValue.length]) + .build(); + + return new Operation(OperationType.Delete, newIndex, { + prevValue: this.data.newValue, + newValue: this.data.prevValue, + }); + case OperationType.Delete: + return new Operation(OperationType.Insert, index, { + prevValue: this.data.newValue, + newValue: this.data.prevValue, + }); + case OperationType.Modify: + return new Operation(OperationType.Modify, index, { + prevValue: this.data.newValue, + newValue: this.data.prevValue, + }); + } + } + + /** + * Transforms the operation against another operation + * + * @param againstOp - operation to transform against + */ + public transform(againstOp: Operation): Operation { + if (!this.index.isTextIndex || !againstOp.index.isTextIndex) { + throw new Error('Unsupported index'); + } + + if (this.type === OperationType.Modify || againstOp.type === OperationType.Modify) { + throw new Error('Unsupported operation type'); + } + + /** + * Do not transform operations if they are on different blocks or documents + */ + if (this.index.documentId !== againstOp.index.documentId || this.index.blockIndex !== againstOp.index.blockIndex) { + return this; + } + + const [ receivedStartIndex ] = this.index.textRange!; + const [ localStartIndex ] = againstOp.index.textRange!; + + switch (true) { + case this.type === OperationType.Insert && againstOp.type === OperationType.Insert: + if (receivedStartIndex <= localStartIndex) { + return this; + } else { + return this.shiftOperation(againstOp.data.newValue.length); + } + + case this.type === OperationType.Delete && againstOp.type === OperationType.Delete: + if (receivedStartIndex < localStartIndex) { + return this; + } else if (receivedStartIndex > localStartIndex) { + return this.shiftOperation(-againstOp.data.prevValue.length); + } else { + // If both delete at the same index, adjust the length of deletion + const minLength = Math.min(this.data.prevValue.length, againstOp.data.prevValue.length); + + return new Operation(OperationType.Delete, this.index, { + prevValue: this.data.prevValue.slice(minLength), + newValue: '', + }); + } + + case this.type === OperationType.Insert && againstOp.type === OperationType.Delete: + if (receivedStartIndex <= localStartIndex) { + return this; + } else { + return this.shiftOperation(-againstOp.data.prevValue.length); + } + + case this.type === OperationType.Delete && againstOp.type === OperationType.Insert: + if (receivedStartIndex < localStartIndex) { + return this; + } else { + return this.shiftOperation(againstOp.data.newValue.length); + } + + default: + throw new Error('Unsupported operation type'); + } + } + + /** + * Shifts the operation by the given shift value (by adjusting the text range) + * + * @param shift - shift value + */ + private shiftOperation(shift: number): Operation { + if (!this.index.isTextIndex) { + throw new Error('Unsupported index'); + } + + const [ textRangeStart ] = this.index.textRange!; + + return new Operation( + this.type, + new IndexBuilder().from(this.index) + .addTextRange([textRangeStart + shift, textRangeStart + shift]) + .build(), + this.data + ); + } } diff --git a/packages/collaboration-manager/src/Transformer.ts b/packages/collaboration-manager/src/Transformer.ts deleted file mode 100644 index 0b449be..0000000 --- a/packages/collaboration-manager/src/Transformer.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { IndexBuilder } from '@editorjs/model'; -import { Operation, OperationType } from './Operation.js'; - -/** - * Utility class to transform operations - */ -export class Transformer { - /** - * Makes an inverse operation - * - * @param operation - operation to inverse - */ - public static inverse(operation: Operation): Operation { - const index = operation.index; - - switch (operation.type) { - case OperationType.Insert: - - const textRange = index.textRange; - - if (textRange == undefined) { - throw new Error('Unsupported index'); - } - - const [ textRangeStart ] = textRange; - - const newIndex = new IndexBuilder() - .from(index) - .addTextRange([textRangeStart, textRangeStart + operation.data.newValue.length]) - .build(); - - return new Operation(OperationType.Delete, newIndex, { - prevValue: operation.data.newValue, - newValue: operation.data.prevValue, - }); - case OperationType.Delete: - return new Operation(OperationType.Insert, index, { - prevValue: operation.data.newValue, - newValue: operation.data.prevValue, - }); - case OperationType.Modify: - return new Operation(OperationType.Modify, index, { - prevValue: operation.data.newValue, - newValue: operation.data.prevValue, - }); - default: - throw new Error('Unknown operation type'); - } - } -} diff --git a/packages/collaboration-manager/src/UndoRedoManager.ts b/packages/collaboration-manager/src/UndoRedoManager.ts index 0504699..cb65251 100644 --- a/packages/collaboration-manager/src/UndoRedoManager.ts +++ b/packages/collaboration-manager/src/UndoRedoManager.ts @@ -1,5 +1,4 @@ import type { Operation } from './Operation.js'; -import { Transformer } from './Transformer.js'; /** * Manages undo and redo operations @@ -25,7 +24,7 @@ export class UndoRedoManager { return; } - const inversedOperation = Transformer.inverse(operation); + const inversedOperation = operation.inverse(); this.#redoStack.push(inversedOperation); @@ -42,9 +41,9 @@ export class UndoRedoManager { return; } - const inversedOperation = Transformer.inverse(operation); + const inversedOperation = operation.inverse(); - this.#undoStack.push(Transformer.inverse(inversedOperation)); + this.#undoStack.push(operation); return inversedOperation; }