diff --git a/.github/workflows/collaboration-manager.yml b/.github/workflows/collaboration-manager.yml index b40eca5e..7fec28ae 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/package.json b/packages/collaboration-manager/package.json index c3575ce3..09e375d0 100644 --- a/packages/collaboration-manager/package.json +++ b/packages/collaboration-manager/package.json @@ -13,7 +13,7 @@ "lint:fix": "yarn lint --fix", "test": "node --experimental-vm-modules $(yarn bin jest)", "test:coverage": "yarn test --coverage=true", - "test:mutations": "stryker run", + "test:mutations": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" stryker run", "clear": "rm -rf ./dist && rm -rf ./tsconfig.build.tsbuildinfo" }, "dependencies": { diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 6637895a..e6fbabd9 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -6,6 +6,15 @@ import { Operation, OperationType } from './Operation.js'; describe('CollaborationManager', () => { describe('applyOperation', () => { + it('should throw an error on unknown operation type', () => { + const model = new EditorJSModel(); + + const collaborationManager = new CollaborationManager(model); + + // @ts-expect-error - for test purposes + expect(() => collaborationManager.applyOperation(new Operation('unknown', new IndexBuilder().build(), 'hello'))).toThrow('Unknown operation type'); + }); + it('should add text on apply Insert Operation', () => { const model = new EditorJSModel(); @@ -26,8 +35,7 @@ describe('CollaborationManager', () => { .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { - prevValue: '', - newValue: 'test', + payload: 'test', }); collaborationManager.applyOperation(operation); @@ -47,7 +55,6 @@ describe('CollaborationManager', () => { }); }); - it('should remove text on apply Remove Operation', () => { const model = new EditorJSModel(); @@ -69,8 +76,45 @@ describe('CollaborationManager', () => { 3, 5]) .build(); const operation = new Operation(OperationType.Delete, index, { - prevValue: '11', - newValue: '', + payload: '11', + }); + + collaborationManager.applyOperation(operation); + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'hello', + fragments: [], + }, + }, + } ], + properties: {}, + }); + }); + + it('should add Block on apply Insert Operation', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Insert, index, { + payload: [ { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + }, + }, + } ], }); collaborationManager.applyOperation(operation); @@ -89,6 +133,131 @@ describe('CollaborationManager', () => { properties: {}, }); }); + + it('should remove Block on apply Delete Operation', () => { + const model = new EditorJSModel(); + const block = { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + }, + }, + }; + + model.initializeDocument({ + blocks: [ block ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Delete, index, { + payload: [ block ], + }); + + collaborationManager.applyOperation(operation); + expect(model.serialized).toStrictEqual({ + blocks: [], + properties: {}, + }); + }); + + it('should format text on apply Modify Operation', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: 'Hello world', + $t: 't', + }, + }, + } ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); + const operation = new Operation(OperationType.Modify, index, { + payload: { + tool: 'bold', + }, + prevPayload: null, + }); + + collaborationManager.applyOperation(operation); + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'Hello world', + fragments: [ { + tool: 'bold', + range: [0, 5], + } ], + }, + }, + } ], + properties: {}, + }); + }); + + it('should unformat text on apply Modify Operation', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: 'Hello world', + $t: 't', + fragments: [ { + tool: 'bold', + range: [0, 5], + } ], + }, + }, + } ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 3]) + .build(); + const operation = new Operation(OperationType.Modify, index, { + payload: null, + prevPayload: { + tool: 'bold', + }, + }); + + collaborationManager.applyOperation(operation); + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'Hello world', + fragments: [ { + tool: 'bold', + range: [3, 5], + } ], + }, + }, + } ], + properties: {}, + }); + }); }); describe('undo logic', () => { @@ -112,8 +281,7 @@ describe('CollaborationManager', () => { .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { - prevValue: '', - newValue: 'test', + payload: 'test', }); collaborationManager.applyOperation(operation); @@ -155,8 +323,7 @@ describe('CollaborationManager', () => { 3, 5]) .build(); const operation = new Operation(OperationType.Delete, index, { - prevValue: '11', - newValue: '', + payload: '11', }); collaborationManager.applyOperation(operation); @@ -197,8 +364,7 @@ describe('CollaborationManager', () => { .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { - prevValue: '', - newValue: 'test', + payload: 'test', }); collaborationManager.applyOperation(operation); @@ -240,8 +406,7 @@ describe('CollaborationManager', () => { .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { - prevValue: '', - newValue: 'test', + payload: 'test', }); collaborationManager.applyOperation(operation); @@ -263,5 +428,343 @@ describe('CollaborationManager', () => { properties: {}, }); }); + + it('should undo block insert', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Insert, index, { + payload: [ { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + }, + }, + } ], + }); + + collaborationManager.applyOperation(operation); + + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [], + properties: {}, + }); + }); + + it('should undo text formatting', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: 'Hello world', + $t: 't', + }, + }, + } ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); + const operation = new Operation(OperationType.Modify, index, { + payload: { + tool: 'bold', + }, + prevPayload: null, + }); + + collaborationManager.applyOperation(operation); + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'Hello world', + fragments: [], + }, + }, + } ], + properties: {}, + }); + }); + + it('should undo text unformatting', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: 'Hello world', + $t: 't', + fragments: [ { + tool: 'bold', + range: [0, 5], + } ], + }, + }, + } ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 3]) + .build(); + const operation = new Operation(OperationType.Modify, index, { + payload: null, + prevPayload: { + tool: 'bold', + }, + }); + + collaborationManager.applyOperation(operation); + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'Hello world', + fragments: [ { + tool: 'bold', + range: [0, 5], + } ], + }, + }, + } ], + properties: {}, + }); + }); + + it('should redo text unformatting', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: 'Hello world', + $t: 't', + fragments: [ { + tool: 'bold', + range: [0, 5], + } ], + }, + }, + } ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 3]) + .build(); + const operation = new Operation(OperationType.Modify, index, { + payload: null, + prevPayload: { + tool: 'bold', + }, + }); + + collaborationManager.applyOperation(operation); + collaborationManager.undo(); + collaborationManager.redo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'Hello world', + fragments: [ { + tool: 'bold', + range: [3, 5], + } ], + }, + }, + } ], + properties: {}, + }); + }); + + it('should undo block deletion', () => { + const model = new EditorJSModel(); + const block = { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + fragments: [], + }, + }, + tunes: {}, + }; + + model.initializeDocument({ + blocks: [ block ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Delete, index, { + payload: [ block ], + }); + + collaborationManager.applyOperation(operation); + + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ block ], + properties: {}, + }); + }); + }); + + it('should undo the next operation', () => { + const model = new EditorJSModel(); + const block = { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + fragments: [], + }, + }, + tunes: {}, + }; + + model.initializeDocument({ + blocks: [ block ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Delete, index, { + payload: [ block ], + }); + + collaborationManager.applyOperation(operation); + + collaborationManager.undo(); + + collaborationManager.applyOperation(operation); + + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ block ], + properties: {}, + }); + }); + + it('should undo after redo', () => { + const model = new EditorJSModel(); + const block = { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + fragments: [], + }, + }, + tunes: {}, + }; + + model.initializeDocument({ + blocks: [ block ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Delete, index, { + payload: [ block ], + }); + + collaborationManager.applyOperation(operation); + + collaborationManager.undo(); + collaborationManager.redo(); + collaborationManager.undo(); + + /** + * Here to kill the mutant when redo operations are added to the undo stack twice + */ + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ block ], + properties: {}, + }); + }); + + + it('should undo the next operation after redo', () => { + const model = new EditorJSModel(); + const block = { + name: 'paragraph', + data: { + text: { + value: 'hello', + $t: 't', + fragments: [], + }, + }, + tunes: {}, + }; + + model.initializeDocument({ + blocks: [ block ], + }); + const collaborationManager = new CollaborationManager(model); + const index = new IndexBuilder().addBlockIndex(0) + .build(); + const operation = new Operation(OperationType.Delete, index, { + payload: [ block ], + }); + + collaborationManager.applyOperation(operation); + + collaborationManager.undo(); + collaborationManager.redo(); + + collaborationManager.applyOperation( + new Operation(OperationType.Insert, index, { + payload: [ block ], + }) + ); + + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [], + properties: {}, + }); }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 04857a12..6b51599b 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -1,6 +1,14 @@ -import type { EditorJSModel, ModelEvents } from '@editorjs/model'; -import { EventType, TextAddedEvent, TextRemovedEvent } from '@editorjs/model'; -import { Operation, OperationType } from './Operation.js'; +import { + BlockAddedEvent, type BlockNodeSerialized, + BlockRemovedEvent, + type EditorJSModel, + EventType, + type ModelEvents, + TextAddedEvent, + TextFormattedEvent, TextRemovedEvent, + TextUnformattedEvent +} from '@editorjs/model'; +import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; /** @@ -78,22 +86,18 @@ export class CollaborationManager { * @param operation - operation to apply */ public applyOperation(operation: Operation): void { - const { blockIndex, dataKey, textRange } = operation.index; - - if (blockIndex == undefined || dataKey == undefined || textRange == undefined) { - throw new Error('Unsupported index'); - } - switch (operation.type) { case OperationType.Insert: - this.#model.insertData(operation.index, operation.data.newValue); + this.#model.insertData(operation.index, operation.data.payload as string | BlockNodeSerialized[]); break; case OperationType.Delete: - this.#model.removeData(operation.index); + this.#model.removeData(operation.index, operation.data.payload as string | BlockNodeSerialized[]); break; case OperationType.Modify: - console.log('modify operation is not implemented yet'); - // this.#model.insertText(blockIndex, dataKey, operation.data.newValue); + this.#model.modifyData(operation.index, { + value: operation.data.payload, + previous: (operation.data as ModifyOperationData).prevPayload, + }); break; default: throw new Error('Unknown operation type'); @@ -111,20 +115,45 @@ export class CollaborationManager { } let operation: Operation | null = null; + /** + * @todo add all model events + */ switch (true) { case (e instanceof TextAddedEvent): operation = new Operation(OperationType.Insert, e.detail.index, { - prevValue: '', - newValue: e.detail.data, + payload: e.detail.data, }); break; case (e instanceof TextRemovedEvent): operation = new Operation(OperationType.Delete, e.detail.index, { - prevValue: e.detail.data, - newValue: '', + payload: e.detail.data, + }); + break; + case (e instanceof TextFormattedEvent): + operation = new Operation(OperationType.Modify, e.detail.index, { + payload: e.detail.data, + prevPayload: null, + }); + break; + case (e instanceof TextUnformattedEvent): + operation = new Operation(OperationType.Modify, e.detail.index, { + prevPayload: e.detail.data, + payload: null, + }); + break; + case (e instanceof BlockAddedEvent): + operation = new Operation(OperationType.Insert, e.detail.index, { + payload: [ e.detail.data ], + }); + break; + case (e instanceof BlockRemovedEvent): + operation = new Operation(OperationType.Delete, e.detail.index, { + payload: [ e.detail.data ], }); break; + // Stryker disable next-line ConditionalExpression default: + // Stryker disable next-line StringLiteral console.error('Unknown event type', e); } diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts new file mode 100644 index 00000000..c41fa64f --- /dev/null +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -0,0 +1,318 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import type { BlockNodeSerialized, DataKey, DocumentIndex } from '@editorjs/model'; +import { IndexBuilder } from '@editorjs/model'; +import { describe } from '@jest/globals'; +import { type InsertOrDeleteOperationData, type ModifyOperationData, Operation, OperationType } from './Operation.js'; + +const createOperation = ( + type: OperationType, + startIndex: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: string | [ BlockNodeSerialized ] | Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prevValue?: Record +): Operation => { + const index = new IndexBuilder() + .addBlockIndex(0); + + if (Array.isArray(value)) { + index.addBlockIndex(startIndex); + } else { + index.addDataKey('text' as DataKey).addTextRange([startIndex, startIndex]); + } + + const data: InsertOrDeleteOperationData | ModifyOperationData = { + payload: value as ArrayLike, + prevPayload: null, + }; + + if (type === OperationType.Modify && prevValue !== undefined) { + (data as ModifyOperationData).prevPayload = prevValue; + } + + return new Operation( + type, + index.build(), + data + ); +}; + + +describe('Operation', () => { + describe('.transform()', () => { + it('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); + }); + + it('should not change operation if data keys are different', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, 'def'); + + localOp.index.dataKey = 'dataKey2' as DataKey; + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + it('should not change operation if operation 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); + }); + + it('should throw an error if unsupported operation type is provided', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'def'); + // @ts-expect-error — for test purposes + const localOp = createOperation('unsupported', 0, 'def'); + + expect(() => receivedOp.transform(localOp)).toThrow('Unsupported operation type'); + }); + + it('should not transform relative to the Modify operation (as Modify operation doesn\'t change index)', () => { + const receivedOp = createOperation(OperationType.Insert, 0, 'abc'); + const localOp = createOperation(OperationType.Modify, 0, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + describe('Transformation relative to Insert operation', () => { + it('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); + }); + + it('should transform an index for a received operation if it is after a local one', () => { + const receivedOp = createOperation(OperationType.Delete, 3, 'def'); + const localOp = createOperation(OperationType.Insert, 0, 'abc'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([6, 6]); + }); + + it('should transform a received operation if it is at the same position as a local one', () => { + const receivedOp = createOperation(OperationType.Modify, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, 'def'); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([3, 3]); + }); + + it('should not change the text index if local op is a Block operation', () => { + const receivedOp = createOperation(OperationType.Modify, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([0, 0]); + }); + + it('should not change the operation if local op is a Block operation after a received one', () => { + const receivedOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'abc' }, + } ]); + const localOp = createOperation(OperationType.Insert, 1, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + it('should adjust the block index if local op is a Block operation before a received one', () => { + const receivedOp = createOperation(OperationType.Insert, 1, [ { + name: 'paragraph', + data: { text: 'abc' }, + } ]); + const localOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.blockIndex).toEqual(2); + }); + + it('should adjust the block index if local op is a Block operation at the same index as a received one', () => { + const receivedOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'abc' }, + } ]); + const localOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.blockIndex).toEqual(1); + }); + }); + + describe('Transformation relative to Delete operation', () => { + it('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); + }); + + it('should transform 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]); + }); + + 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 transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([0, 0]); + }); + + it('should not change the text index if local op is a Block operation', () => { + const receivedOp = createOperation(OperationType.Modify, 1, 'abc'); + const localOp = createOperation(OperationType.Delete, 0, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([1, 1]); + }); + + it('should not change the text index if local op is a Block operation', () => { + const receivedOp = createOperation(OperationType.Modify, 0, 'abc'); + const localOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.textRange).toEqual([0, 0]); + }); + + it('should not change the operation if local op is a Block operation after a received one', () => { + const receivedOp = createOperation(OperationType.Insert, 0, [ { + name: 'paragraph', + data: { text: 'abc' }, + } ]); + const localOp = createOperation(OperationType.Delete, 1, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp).toEqual(receivedOp); + }); + + it('should adjust the block index if local op is a Block operation before a received one', () => { + const receivedOp = createOperation(OperationType.Insert, 1, [ { + name: 'paragraph', + data: { text: 'abc' }, + } ]); + const localOp = createOperation(OperationType.Delete, 0, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + + const transformedOp = receivedOp.transform(localOp); + + 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', () => { + const receivedOp = createOperation(OperationType.Insert, 1, [ { + name: 'paragraph', + data: { text: 'abc' }, + } ]); + const localOp = createOperation(OperationType.Delete, 1, [ { + name: 'paragraph', + data: { text: 'hello' }, + } ]); + + const transformedOp = receivedOp.transform(localOp); + + expect(transformedOp.index.blockIndex).toEqual(0); + }); + }); + }); + + describe('.inverse()', () => { + it('should change the type of Insert operation to Delete operation', () => { + const op = createOperation(OperationType.Insert, 0, 'abc'); + const inverted = op.inverse(); + + expect(inverted.type).toEqual(OperationType.Delete); + }); + + it('should change the type of Delete operation to Insert operation', () => { + const op = createOperation(OperationType.Delete, 0, 'abc'); + const inverted = op.inverse(); + + expect(inverted.type).toEqual(OperationType.Insert); + }); + + it('should not change the type of Modify operation', () => { + const op = createOperation(OperationType.Modify, 0, { bold: true }, { bold: false }); + const inverted = op.inverse(); + + expect(inverted.type).toEqual(OperationType.Modify); + }); + + it('should not change index', () => { + const op = createOperation(OperationType.Insert, 0, 'abc'); + const inverted = op.inverse(); + + expect(inverted.index).toEqual(op.index); + }); + + it('should not change the data', () => { + const op = createOperation(OperationType.Insert, 0, 'abc'); + const inverted = op.inverse(); + + expect(inverted.data).toEqual(op.data); + }); + + it('should flip the current and previous values of Modify operations', () => { + const op = createOperation(OperationType.Modify, 0, { bold: true }, { bold: false }); + const inverted = op.inverse(); + + expect(inverted.data).toEqual({ payload: { bold: false }, + prevPayload: { bold: true } }); + }); + + it('should throw an error if unsupported operation type is provided', () => { + // @ts-expect-error — for test purposes + const op = createOperation('unsupported', 0, 'def'); + + expect(() => op.inverse()).toThrow('Unsupported operation type'); + }); + }); +}); diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index 79ea904b..8cb66731 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, type BlockNodeSerialized } from '@editorjs/model'; /** * Type of the operation @@ -9,30 +9,54 @@ export enum OperationType { Modify = 'modify' } +/** + * Operation payload could be string (for Text operations), or serialized Block data (for Block operations) + */ +type OperationPayload = string | BlockNodeSerialized; + /** * Data for the operation */ -export interface OperationData { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface InsertOrDeleteOperationData { /** - * Value before the operation + * Operation payload */ - prevValue: string; + payload: ArrayLike; +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface ModifyOperationData = Record> { /** - * Value after the operation + * Previous payload for undo/redo purposes */ - newValue: string; + payload?: T | null; + + /** + * Previous payload for undo/redo purposes + */ + prevPayload?: T | null; } +export type OperationTypeToData = T extends OperationType.Modify + ? ModifyOperationData + : InsertOrDeleteOperationData; + +export type InvertedOperationType = T extends OperationType.Insert + ? OperationType.Delete + : T extends OperationType.Delete + ? OperationType.Insert + : OperationType.Modify; + /** * Class representing operation on the document model tree */ -export class Operation { +export class Operation { /** * Operation type */ - public type: OperationType; + public type: T; /** * Index in the document model tree @@ -42,7 +66,7 @@ export class Operation { /** * Operation data */ - public data: OperationData; + public data: OperationTypeToData; /** * Creates an instance of Operation @@ -51,9 +75,121 @@ export class Operation { * @param index - index in the document model tree * @param data - operation data */ - constructor(type: OperationType, index: Index, data: OperationData) { + constructor(type: T, index: Index, data: OperationTypeToData) { this.type = type; this.index = index; this.data = data; } + + /** + * Returns an inverted operation + */ + public inverse(): Operation> { + const index = this.index; + + switch (this.type) { + case OperationType.Insert: { + const data = this.data as InsertOrDeleteOperationData; + + return new Operation(OperationType.Delete, index, data) as Operation>; + } + case OperationType.Delete: { + const data = this.data as InsertOrDeleteOperationData; + + return new Operation(OperationType.Insert, index, data) as Operation>; + } + case OperationType.Modify: { + const data = this.data as ModifyOperationData; + + return new Operation(OperationType.Modify, index, { + payload: data.prevPayload, + prevPayload: data.payload, + }) as Operation>; + } + + default: + throw Error('Unsupported operation type'); + } + } + + /** + * Transforms the operation against another operation + * + * @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'); + } + + return new Operation( + this.type, + newIndexBuilder.build(), + this.data + ); + } + + /** + * 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/Transformer.ts b/packages/collaboration-manager/src/Transformer.ts deleted file mode 100644 index 0b449bef..00000000 --- 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.spec.ts b/packages/collaboration-manager/src/UndoRedoManager.spec.ts new file mode 100644 index 00000000..f45905fb --- /dev/null +++ b/packages/collaboration-manager/src/UndoRedoManager.spec.ts @@ -0,0 +1,101 @@ +import { IndexBuilder } from '@editorjs/model'; +import { describe } from '@jest/globals'; +import { Operation, OperationType } from './Operation.js'; +import { UndoRedoManager } from './UndoRedoManager.js'; + +describe('UndoRedoManager', () => { + it('should return inverted operation on undo', () => { + const manager = new UndoRedoManager(); + + const op = new Operation( + OperationType.Insert, + new IndexBuilder() + .addBlockIndex(0) + .build(), + { + payload: [ { + name: 'paragraph', + data: { text: 'editor.js' }, + } ], + }); + + manager.put(op); + + const invertedOp = manager.undo(); + + expect(invertedOp).toEqual(op.inverse()); + }); + + it('should return undefined on undo if there is no operations in stack', () => { + const manager = new UndoRedoManager(); + + expect(manager.undo()).toBeUndefined(); + }); + + it('should return undefined on redo if there is no operations in stack', () => { + const manager = new UndoRedoManager(); + + expect(manager.redo()).toBeUndefined(); + }); + + it('should return the original operation on redo', () => { + const manager = new UndoRedoManager(); + + const op = new Operation( + OperationType.Insert, + new IndexBuilder() + .addBlockIndex(0) + .build(), + { + payload: [ { + name: 'paragraph', + data: { text: 'editor.js' }, + } ], + }); + + manager.put(op); + + manager.undo(); + + const result = manager.redo(); + + expect(result).toEqual(op); + }); + + it('should flush the redo stack on put', () => { + const manager = new UndoRedoManager(); + + const op = new Operation( + OperationType.Insert, + new IndexBuilder() + .addBlockIndex(0) + .build(), + { + payload: [ { + name: 'paragraph', + data: { text: 'editor.js' }, + } ], + }); + + + const newOp = new Operation( + OperationType.Insert, + new IndexBuilder() + .addBlockIndex(0) + .build(), + { + payload: [ { + name: 'paragraph', + data: { text: 'hello' }, + } ], + }); + + manager.put(op); + manager.undo(); + manager.put(newOp); + + const result = manager.redo(); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/collaboration-manager/src/UndoRedoManager.ts b/packages/collaboration-manager/src/UndoRedoManager.ts index 05046992..08c7cbac 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,11 +24,11 @@ export class UndoRedoManager { return; } - const inversedOperation = Transformer.inverse(operation); + const invertedOperation = operation.inverse(); - this.#redoStack.push(inversedOperation); + this.#redoStack.push(invertedOperation); - return inversedOperation; + return invertedOperation; } /** @@ -42,11 +41,11 @@ export class UndoRedoManager { return; } - const inversedOperation = Transformer.inverse(operation); + const invertedOperation = operation.inverse(); - this.#undoStack.push(Transformer.inverse(inversedOperation)); + this.#undoStack.push(invertedOperation); - return inversedOperation; + return invertedOperation; } /** diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index 4f362303..a62fca8d 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -13,6 +13,7 @@ describe('EditorJSModel', () => { 'addBlock', 'insertData', 'removeData', + 'modifyData', 'updateTuneData', 'updateValue', 'removeBlock', diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index 44194f02..c45b4e02 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -4,7 +4,7 @@ import type { Index } from './entities/index.js'; import { type BlockNodeSerialized, EditorDocument } from './entities/index.js'; import { EventBus, EventType } from './EventBus/index.js'; import type { ModelEvents, CaretManagerCaretUpdatedEvent, CaretManagerEvents } from './EventBus/index.js'; -import { BaseDocumentEvent } from './EventBus/events/BaseEvent.js'; +import { BaseDocumentEvent, type ModifiedEventData } from './EventBus/events/BaseEvent.js'; import type { Constructor } from './utils/types.js'; import { CaretManager } from './CaretManagement/index.js'; @@ -193,9 +193,9 @@ export class EditorJSModel extends EventBus { * Inserts data to the specified index * * @param index - index to insert data - * @param data - data to insert + * @param data - data to insert (text or blocks) */ - public insertData(index: Index, data: unknown): void { + public insertData(index: Index, data: string | BlockNodeSerialized[]): void { this.#document.insertData(index, data); } @@ -203,9 +203,20 @@ export class EditorJSModel extends EventBus { * Removes data from the specified index * * @param index - index to remove data from + * @param data - text or blocks to remove */ - public removeData(index: Index): void { - this.#document.removeData(index); + public removeData(index: Index, data: string | BlockNodeSerialized[]): void { + this.#document.removeData(index, data); + } + + /** + * Modifies data for the specific index + * + * @param index - index of data to modify + * @param data - data to modify (includes current and previous values) + */ + public modifyData(index: Index, data: ModifiedEventData): void { + this.#document.modifyData(index, data); } /** diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 20e574c7..2ececac9 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -850,10 +850,10 @@ describe('EditorDocument', () => { .build(); - document.insertData(index, block); + document.insertData(index, [ block.serialized ]); expect(spy) - .toHaveBeenCalledWith(block, blockIndex); + .toHaveBeenCalledWith(block.serialized, blockIndex); }); }); @@ -882,7 +882,7 @@ describe('EditorDocument', () => { .addTextRange([0, rangeEnd]) .build(); - document.removeData(index); + document.removeData(index, 'hello'); expect(spy) .toHaveBeenCalledWith(blockIndex, dataKey, 0, rangeEnd); @@ -894,8 +894,14 @@ describe('EditorDocument', () => { .addBlockIndex(blockIndex) .build(); + const blockData = { + name: 'paragraph', + data: { text: 'editor.js' }, + }; - document.removeData(index); + document.removeData(index, [ + blockData, + ]); expect(spy) .toHaveBeenCalledWith(blockIndex); diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 67059b0a..94630e3d 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -3,7 +3,7 @@ import { BlockNode } from '../BlockNode/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import type { EditorDocumentSerialized, EditorDocumentConstructorParameters, Properties } from './types'; import type { BlockTuneName } from '../BlockTune'; -import type { InlineFragment, InlineToolData, InlineToolName } from '../inline-fragments'; +import { type InlineFragment, type InlineToolData, type InlineToolName } from '../inline-fragments/index.js'; import { IoCContainer, TOOLS_REGISTRY } from '../../IoC/index.js'; import { ToolsRegistry } from '../../tools/index.js'; import type { BlockNodeSerialized } from '../BlockNode/types'; @@ -18,10 +18,10 @@ import type { import { BlockAddedEvent, BlockRemovedEvent, - PropertyModifiedEvent + PropertyModifiedEvent, type TextFormattedEventData, type TextUnformattedEventData } from '../../EventBus/events/index.js'; import type { Constructor } from '../../utils/types.js'; -import { BaseDocumentEvent } from '../../EventBus/events/BaseEvent.js'; +import { BaseDocumentEvent, type ModifiedEventData } from '../../EventBus/events/BaseEvent.js'; import type { Index } from '../Index/index.js'; /** @@ -340,17 +340,17 @@ export class EditorDocument extends EventBus { * Inserts data to the specified index * * @param index - index to insert data - * @param data - data to insert + * @param data - data to insert (text or blocks) */ - public insertData(index: Index, data: unknown): void { + public insertData(index: Index, data: string | BlockNodeSerialized[]): void { switch (true) { case index.isTextIndex: this.insertText(index.blockIndex!, index.dataKey!, data as string, index.textRange![0]); break; case index.isBlockIndex: - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - this.addBlock(data as Parameters[0], index.blockIndex); + (data as BlockNodeSerialized[]) + .forEach((blockData, i) => this.addBlock(blockData, index.blockIndex! + i)); break; default: throw new Error('Unsupported index'); @@ -361,21 +361,44 @@ export class EditorDocument extends EventBus { * Removes data from the specified index * * @param index - index to remove data from + * @param data - text or blocks to remove */ - public removeData(index: Index): void { + public removeData(index: Index, data: string | BlockNodeSerialized[]): void { switch (true) { case index.isTextIndex: - this.removeText(index.blockIndex!, index.dataKey!, index.textRange![0], index.textRange![1]); + this.removeText(index.blockIndex!, index.dataKey!, index.textRange![0], index.textRange![0] + data.length); break; case index.isBlockIndex: - this.removeBlock(index.blockIndex!); + (data as BlockNodeSerialized[]).forEach(() => this.removeBlock(index.blockIndex!)); break; default: throw new Error('Unsupported index'); } } + /** + * Modifies data for the specific index + * + * @param index - index of data to modify + * @param data - data to modify (includes current and previous values) + */ + public modifyData(index: Index, data: ModifiedEventData): void { + switch (true) { + case index.isTextIndex: + if (data.value !== null) { + this.format(index.blockIndex!, index.dataKey!, (data.value as TextFormattedEventData).tool, index.textRange![0], index.textRange![1]); + } else if (data.previous !== null) { + this.unformat(index.blockIndex!, index.dataKey!, (data.previous as TextUnformattedEventData).tool, index.textRange![0], index.textRange![1]); + } + + default: + /** + * @todo implement other actions + */ + } + } + /** * Listens to BlockNode events and bubbles them to the EditorDocument *