diff --git a/.github/workflows/ot-server.yml b/.github/workflows/ot-server.yml new file mode 100644 index 00000000..36d1d63a --- /dev/null +++ b/.github/workflows/ot-server.yml @@ -0,0 +1,48 @@ +name: OT server check +on: + pull_request: + merge_group: + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - run: yarn + + - name: Build the package + uses: ./.github/actions/build + with: + package-name: '@editorjs/ot-server' + + - name: Run ESLint check + uses: ./.github/actions/lint + with: + package-name: '@editorjs/ot-server' + + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - run: yarn + + - name: Build the package + uses: ./.github/actions/build + with: + package-name: '@editorjs/ot-server' + + - name: Run unit tests + uses: ./.github/actions/unit-tests + with: + package-name: '@editorjs/ot-server' + working-directory: './packages/ot-server' + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build the package + uses: ./.github/actions/build + with: + package-name: '@editorjs/ot-server' diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 18ea9fb6..81404897 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -6,7 +6,13 @@ import { beforeAll, jest } from '@jest/globals'; import { CollaborationManager } from './CollaborationManager.js'; import { Operation, OperationType } from './Operation.js'; -const config: Partial = { userId: 'user' }; +const userId = 'user'; +const documentId = 'document'; + +const config: CoreConfig = { + userId, + documentId: documentId, +}; describe('CollaborationManager', () => { beforeAll(() => { @@ -15,16 +21,16 @@ describe('CollaborationManager', () => { describe('applyOperation', () => { it('should throw an error on unknown operation type', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, 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(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -37,17 +43,18 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { payload: 'test', - }); + }, userId); collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -64,7 +71,7 @@ describe('CollaborationManager', () => { }); it('should remove text on apply Remove Operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -77,7 +84,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([ @@ -85,10 +92,11 @@ describe('CollaborationManager', () => { .build(); const operation = new Operation(OperationType.Delete, index, { payload: '11', - }); + }, userId); collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -105,12 +113,12 @@ describe('CollaborationManager', () => { }); it('should add Block on apply Insert Operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -123,10 +131,11 @@ describe('CollaborationManager', () => { }, }, } ], - }); + }, userId); collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -143,7 +152,7 @@ describe('CollaborationManager', () => { }); it('should remove Block on apply Delete Operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); const block = { name: 'paragraph', data: { @@ -157,22 +166,23 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { payload: [ block ], - }); + }, userId); collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [], properties: {}, }); }); it('should format text on apply Modify Operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -185,7 +195,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -195,10 +205,11 @@ describe('CollaborationManager', () => { tool: 'bold', }, prevPayload: null, - }); + }, userId); collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -218,7 +229,7 @@ describe('CollaborationManager', () => { }); it('should unformat text on apply Modify Operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -235,7 +246,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -245,10 +256,11 @@ describe('CollaborationManager', () => { prevPayload: { tool: 'bold', }, - }); + }, userId); collaborationManager.applyOperation(operation); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -274,7 +286,7 @@ describe('CollaborationManager', () => { }); it('should invert Insert operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -287,18 +299,19 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { payload: 'test', - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -315,7 +328,7 @@ describe('CollaborationManager', () => { }); it('should invert Remove operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -328,7 +341,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([ @@ -336,11 +349,12 @@ describe('CollaborationManager', () => { .build(); const operation = new Operation(OperationType.Delete, index, { payload: '11', - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -357,7 +371,7 @@ describe('CollaborationManager', () => { }); it('should revert only one operation if stack length is 1', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -370,19 +384,20 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { payload: 'test', - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -399,7 +414,7 @@ describe('CollaborationManager', () => { }); it('should revert back to original state after undo and redo operations', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -412,20 +427,21 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) .build(); const operation = new Operation(OperationType.Insert, index, { payload: 'test', - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); collaborationManager.redo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -442,12 +458,12 @@ describe('CollaborationManager', () => { }); it('should undo block insert', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -460,20 +476,21 @@ describe('CollaborationManager', () => { }, }, } ], - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [], properties: {}, }); }); it('should undo text formatting', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -486,7 +503,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -496,12 +513,13 @@ describe('CollaborationManager', () => { tool: 'bold', }, prevPayload: null, - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -518,7 +536,7 @@ describe('CollaborationManager', () => { }); it('should undo text unformatting', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -535,7 +553,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -545,12 +563,13 @@ describe('CollaborationManager', () => { prevPayload: { tool: 'bold', }, - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -570,7 +589,7 @@ describe('CollaborationManager', () => { }); it('should redo text unformatting', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -587,7 +606,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -597,13 +616,14 @@ describe('CollaborationManager', () => { prevPayload: { tool: 'bold', }, - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); collaborationManager.redo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -623,7 +643,7 @@ describe('CollaborationManager', () => { }); it('should undo block deletion', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); const block = { name: 'paragraph', data: { @@ -639,18 +659,19 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { payload: [ block ], - }); + }, userId); collaborationManager.applyOperation(operation); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ block ], properties: {}, }); @@ -658,7 +679,7 @@ describe('CollaborationManager', () => { }); it('should undo the next operation', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); const block = { name: 'paragraph', data: { @@ -674,12 +695,12 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { payload: [ block ], - }); + }, userId); collaborationManager.applyOperation(operation); @@ -690,13 +711,14 @@ describe('CollaborationManager', () => { collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ block ], properties: {}, }); }); it('should undo after redo', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); const block = { name: 'paragraph', data: { @@ -712,12 +734,12 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { payload: [ block ], - }); + }, userId); collaborationManager.applyOperation(operation); @@ -731,6 +753,7 @@ describe('CollaborationManager', () => { collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ block ], properties: {}, }); @@ -738,7 +761,7 @@ describe('CollaborationManager', () => { it('should undo the next operation after redo', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); const block = { name: 'paragraph', data: { @@ -754,12 +777,12 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { payload: [ block ], - }); + }, userId); collaborationManager.applyOperation(operation); @@ -769,19 +792,20 @@ describe('CollaborationManager', () => { collaborationManager.applyOperation( new Operation(OperationType.Insert, index, { payload: [ block ], - }) + }, userId) ); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [], properties: {}, }); }); it('should undo batch', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -794,21 +818,21 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) .build(); const operation1 = new Operation(OperationType.Insert, index1, { payload: 'te', - }); + }, userId); const index2 = new IndexBuilder().from(index1) .addTextRange([1, 1]) .build(); const operation2 = new Operation(OperationType.Insert, index2, { payload: 'st', - }); + }, userId); collaborationManager.applyOperation(operation1); collaborationManager.applyOperation(operation2); @@ -816,6 +840,7 @@ describe('CollaborationManager', () => { collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -832,7 +857,7 @@ describe('CollaborationManager', () => { }); it('should redo batch', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -845,21 +870,21 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) .build(); const operation1 = new Operation(OperationType.Insert, index1, { payload: 'te', - }); + }, userId); const index2 = new IndexBuilder().from(index1) .addTextRange([1, 1]) .build(); const operation2 = new Operation(OperationType.Insert, index2, { payload: 'st', - }); + }, userId); collaborationManager.applyOperation(operation1); collaborationManager.applyOperation(operation2); @@ -868,6 +893,7 @@ describe('CollaborationManager', () => { collaborationManager.redo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, @@ -884,7 +910,7 @@ describe('CollaborationManager', () => { }); it('should not undo operations from not a current user', () => { - const model = new EditorJSModel(); + const model = new EditorJSModel(userId, { identifier: documentId }); model.initializeDocument({ blocks: [ { @@ -897,13 +923,14 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(config, model); + const collaborationManager = new CollaborationManager(config as Required, model); model.insertText('another-user', 0, createDataKey('text'), 'hello', 0); collaborationManager.undo(); expect(model.serialized).toStrictEqual({ + identifier: documentId, blocks: [ { name: 'paragraph', tunes: {}, diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 6b7d9874..8fe70662 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -1,6 +1,6 @@ import { BlockAddedEvent, type BlockNodeSerialized, - BlockRemovedEvent, + BlockRemovedEvent, type DocumentId, type EditorJSModel, EventType, type ModelEvents, @@ -9,6 +9,7 @@ import { TextUnformattedEvent } from '@editorjs/model'; import type { CoreConfig } from '@editorjs/sdk'; +import { OTClient } from './client/index.js'; import { OperationsBatch } from './OperationsBatch.js'; import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; @@ -40,7 +41,12 @@ export class CollaborationManager { /** * Editor's config */ - #config: CoreConfig; + #config: Required; + + /** + * OT Client + */ + #client: OTClient | null = null; /** * Creates an instance of CollaborationManager @@ -48,11 +54,32 @@ export class CollaborationManager { * @param config - Editor's config * @param model - EditorJSModel instance to listen to and apply operations */ - constructor(config: CoreConfig, model: EditorJSModel) { + constructor(config: Required, model: EditorJSModel) { this.#config = config; this.#model = model; this.#undoRedoManager = new UndoRedoManager(); model.addEventListener(EventType.Changed, this.#handleEvent.bind(this)); + + if (this.#config.collaborationServer === undefined) { + return; + } + + this.#client = new OTClient( + this.#config.collaborationServer, + this.#config.userId, + (data) => { + if (!data) { + return; + } + + this.#model.initializeDocument(data); + }, + (op) => { + this.applyOperation(op); + } + ); + + void this.#client.connectDocument(this.#config.documentId! as DocumentId); } /** @@ -105,13 +132,13 @@ export class CollaborationManager { public applyOperation(operation: Operation): void { switch (operation.type) { case OperationType.Insert: - this.#model.insertData(this.#config.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#model.insertData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); break; case OperationType.Delete: - this.#model.removeData(this.#config.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#model.removeData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); break; case OperationType.Modify: - this.#model.modifyData(this.#config.userId, operation.index, { + this.#model.modifyData(operation.userId, operation.index, { value: operation.data.payload, previous: (operation.data as ModifyOperationData).prevPayload, }); @@ -180,11 +207,12 @@ export class CollaborationManager { return; } - if (e.detail.userId !== this.#config.userId) { + if (operation.userId === this.#config.userId) { + void this.#client?.send(operation); + } else { return; } - const onBatchTermination = (batch: OperationsBatch, lastOp?: Operation): void => { const effectiveOp = batch.getEffectiveOperation(); diff --git a/packages/collaboration-manager/src/Operation.spec.ts b/packages/collaboration-manager/src/Operation.spec.ts index c41fa64f..f1f2455f 100644 --- a/packages/collaboration-manager/src/Operation.spec.ts +++ b/packages/collaboration-manager/src/Operation.spec.ts @@ -33,7 +33,8 @@ const createOperation = ( return new Operation( type, index.build(), - data + data, + 'user' ); }; diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index 69b12cf9..45c939b8 100644 --- a/packages/collaboration-manager/src/Operation.ts +++ b/packages/collaboration-manager/src/Operation.ts @@ -38,10 +38,46 @@ export interface ModifyOperationData = Record { + /** + * Operation type + */ + type: T; + + /** + * Serialized index of the operation + */ + index: string; + + /** + * Operation data + */ + data: OperationTypeToData; + + /** + * Revision of the document operation was applied at + */ + rev: number; + + /** + * Identifier of the user who caused the change + */ + userId: string | number; +} + +/** + * Helper type to convert operation type to operation data interface + */ export type OperationTypeToData = T extends OperationType.Modify ? ModifyOperationData : InsertOrDeleteOperationData; +/** + * Helper type to get invert operation type + */ export type InvertedOperationType = T extends OperationType.Insert ? OperationType.Delete : T extends OperationType.Delete @@ -71,7 +107,12 @@ export class Operation { /** * Identifier of a user who created an operation; */ - public userId?: string | number; + public userId: string | number; + + /** + * Document revision on which operation was applied + */ + public rev?: number; /** * Creates an instance of Operation @@ -80,12 +121,48 @@ export class Operation { * @param index - index in the document model tree * @param data - operation data * @param userId - user identifier + * @param rev - document revision */ - constructor(type: T, index: Index, data: OperationTypeToData, userId?: string | number) { + constructor(type: T, index: Index, data: OperationTypeToData, userId: string | number, rev?: number) { this.type = type; this.index = index; this.data = data; this.userId = userId; + this.rev = rev; + } + + /** + * Creates an operation from another operation or serialized operation + * + * @param op - operation to copy + */ + public static from(op: Operation): Operation; + /** + * Creates an operation from another operation or serialized operation + * + * @param json - serialized operation to copy + */ + public static from(json: SerializedOperation): Operation; + /** + * Creates an operation from another operation or serialized operation + * + * @param opOrJSON - operation or serialized operation to copy + */ + public static from(opOrJSON: Operation | SerializedOperation): Operation { + let index: Index; + + /** + * Because of TypeScript guards we need to use an if statement here + */ + if (typeof opOrJSON.index === 'string') { + index = new IndexBuilder().from(opOrJSON.index) + .build(); + } else { + index = new IndexBuilder().from(opOrJSON.index) + .build(); + } + + return new Operation(opOrJSON.type, index, opOrJSON.data, opOrJSON.userId, opOrJSON.rev); } /** @@ -98,12 +175,12 @@ export class Operation { case OperationType.Insert: { const data = this.data as InsertOrDeleteOperationData; - return new Operation(OperationType.Delete, index, data) as Operation>; + return new Operation(OperationType.Delete, index, data, this.userId) as Operation>; } case OperationType.Delete: { const data = this.data as InsertOrDeleteOperationData; - return new Operation(OperationType.Insert, index, data) as Operation>; + return new Operation(OperationType.Insert, index, data, this.userId) as Operation>; } case OperationType.Modify: { const data = this.data as ModifyOperationData; @@ -111,7 +188,7 @@ export class Operation { return new Operation(OperationType.Modify, index, { payload: data.prevPayload, prevPayload: data.payload, - }) as Operation>; + }, this.userId) as Operation>; } default: @@ -174,11 +251,24 @@ export class Operation { throw new Error('Unsupported operation type'); } - return new Operation( - this.type, - newIndexBuilder.build(), - this.data - ); + const operation = Operation.from(this); + + operation.index = newIndexBuilder.build(); + + return operation; + } + + /** + * Serializes an operation + */ + public serialize(): SerializedOperation { + return { + type: this.type, + index: this.index.serialize(), + data: this.data, + userId: this.userId, + rev: this.rev!, + }; } /** diff --git a/packages/collaboration-manager/src/OperationsBatch.spec.ts b/packages/collaboration-manager/src/OperationsBatch.spec.ts index 4dd18d50..7902eea3 100644 --- a/packages/collaboration-manager/src/OperationsBatch.spec.ts +++ b/packages/collaboration-manager/src/OperationsBatch.spec.ts @@ -9,19 +9,22 @@ const templateIndex = new IndexBuilder() .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' }); + 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' } + { payload: 'b' }, + userId ); const onTimeout = jest.fn(); @@ -38,17 +41,20 @@ describe('Batch', () => { .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' }); + 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' } + { payload: 'b' }, + userId ); const onTimeout = jest.fn(); @@ -65,11 +71,13 @@ describe('Batch', () => { .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' }); + const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); const op2 = new Operation( OperationType.Delete, new IndexBuilder().from(templateIndex) @@ -83,7 +91,8 @@ describe('Batch', () => { data: { text: '' }, }, ], - } + }, + userId ); const onTimeout = jest.fn(); @@ -109,9 +118,10 @@ describe('Batch', () => { data: { text: '' }, }, ], - } + }, + userId ); - const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }); + const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); const onTimeout = jest.fn(); @@ -134,9 +144,10 @@ describe('Batch', () => { prevPayload: { tool: 'bold', }, - } + }, + userId ); - const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }); + const op2 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); const onTimeout = jest.fn(); @@ -148,7 +159,7 @@ describe('Batch', () => { }); it('should terminate the batch if the new operation is Modify operation', () => { - const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }); + const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }, userId); const op2 = new Operation( OperationType.Modify, new IndexBuilder().from(templateIndex) @@ -160,7 +171,8 @@ describe('Batch', () => { prevPayload: { tool: 'bold', }, - } + }, + userId ); const onTimeout = jest.fn(); @@ -173,13 +185,14 @@ describe('Batch', () => { }); it('should terminate the batch if operations are of different type', () => { - const op1 = new Operation(OperationType.Delete, templateIndex, { payload: 'a' }); + 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' } + { payload: 'b' }, + userId ); const onTimeout = jest.fn(); @@ -191,14 +204,15 @@ describe('Batch', () => { }); it('should terminate the batch if operations block indexes are not the same', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }); + 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' } + { payload: 'b' }, + userId ); const onTimeout = jest.fn(); @@ -210,14 +224,15 @@ describe('Batch', () => { }); it('should terminate the batch if operations data keys are not the same', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }); + 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' } + { payload: 'b' }, + userId ); const onTimeout = jest.fn(); @@ -229,13 +244,14 @@ describe('Batch', () => { }); it('should terminate the batch if operations index ranges are not adjacent', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }); + 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' } + { payload: 'b' }, + userId ); const onTimeout = jest.fn(); @@ -247,7 +263,7 @@ describe('Batch', () => { }); it('should terminate the batch if timeout is exceeded', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }); + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); const onTimeout = jest.fn(); @@ -267,7 +283,7 @@ describe('Batch', () => { }); it('should return the only operation in the batch as effective operation', () => { - const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }); + const op1 = new Operation(OperationType.Insert, templateIndex, { payload: 'a' }, userId); const onTimeout = jest.fn(); diff --git a/packages/collaboration-manager/src/OperationsBatch.ts b/packages/collaboration-manager/src/OperationsBatch.ts index 85abb64f..51661e59 100644 --- a/packages/collaboration-manager/src/OperationsBatch.ts +++ b/packages/collaboration-manager/src/OperationsBatch.ts @@ -96,7 +96,8 @@ export class OperationsBatch { new IndexBuilder().from(index) .addTextRange(range) .build(), - { payload } + { payload }, + this.#operations[0].userId ); } diff --git a/packages/collaboration-manager/src/UndoRedoManager.spec.ts b/packages/collaboration-manager/src/UndoRedoManager.spec.ts index f45905fb..ba95322b 100644 --- a/packages/collaboration-manager/src/UndoRedoManager.spec.ts +++ b/packages/collaboration-manager/src/UndoRedoManager.spec.ts @@ -3,6 +3,8 @@ import { describe } from '@jest/globals'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; +const userId = 'user'; + describe('UndoRedoManager', () => { it('should return inverted operation on undo', () => { const manager = new UndoRedoManager(); @@ -17,7 +19,9 @@ describe('UndoRedoManager', () => { name: 'paragraph', data: { text: 'editor.js' }, } ], - }); + }, + userId + ); manager.put(op); @@ -51,7 +55,9 @@ describe('UndoRedoManager', () => { name: 'paragraph', data: { text: 'editor.js' }, } ], - }); + }, + userId + ); manager.put(op); @@ -75,7 +81,9 @@ describe('UndoRedoManager', () => { name: 'paragraph', data: { text: 'editor.js' }, } ], - }); + }, + userId + ); const newOp = new Operation( @@ -88,7 +96,9 @@ describe('UndoRedoManager', () => { name: 'paragraph', data: { text: 'hello' }, } ], - }); + }, + userId + ); manager.put(op); manager.undo(); diff --git a/packages/collaboration-manager/src/client/Message.ts b/packages/collaboration-manager/src/client/Message.ts new file mode 100644 index 00000000..9142b604 --- /dev/null +++ b/packages/collaboration-manager/src/client/Message.ts @@ -0,0 +1,55 @@ +import type { DocumentId, EditorDocumentSerialized } from '@editorjs/model'; +import type { MessageType } from './MessageType.js'; +import type { SerializedOperation } from '../Operation.js'; + +/** + * Payload of the handshake message + */ +export interface HandshakePayload { + /** + * Document id to edit + */ + document: DocumentId + + /** + * User identifier + */ + userId: string | number; + + /** + * Document revision + */ + rev: number; + + /** + * Current document state + */ + data?: EditorDocumentSerialized; +} + +/** + * WebSocket message object + */ +export interface Message

{ + /** + * Message type (e.g. Handshake, Operation) + */ + type: MessageType; + + /** + * Message payload + */ + payload: P; +} + +/** + * Handshake websocket message + * + * Client and server do handshake exchange to pass document information and initial state + */ +export type HandshakeMessage = Message; + +/** + * Operation websocket message + */ +export type OperationMessage = Message; diff --git a/packages/collaboration-manager/src/client/MessageType.ts b/packages/collaboration-manager/src/client/MessageType.ts new file mode 100644 index 00000000..651c5e7c --- /dev/null +++ b/packages/collaboration-manager/src/client/MessageType.ts @@ -0,0 +1,7 @@ +/** + * WebSocket message type + */ +export enum MessageType { + Handshake = 'handshake', + Operation = 'operation' +} diff --git a/packages/collaboration-manager/src/client/OTClient.ts b/packages/collaboration-manager/src/client/OTClient.ts new file mode 100644 index 00000000..37199f63 --- /dev/null +++ b/packages/collaboration-manager/src/client/OTClient.ts @@ -0,0 +1,230 @@ +import type { DocumentId, EditorDocumentSerialized } from '@editorjs/model'; +import { Operation, type SerializedOperation } from '../Operation.js'; +import type { HandshakeMessage, HandshakePayload, Message, OperationMessage } from './Message.js'; +import { MessageType } from './MessageType.js'; + +/** + * Class to send operations to websocket server and process remote operations + */ +export class OTClient { + /** + * Current user identifier + */ + #userId: string | number; + + /** + * Current document revision + */ + #rev: number = 0; + + /** + * Array of pending operations to send + */ + #pendingOperations: Operation[] = []; + + /** + * Array of resolved operations + */ + #resolvedOperations: Operation[] = []; + + /** + * Promise resolving the WebSocket client + */ + #ws: Promise; + + /** + * Promise which is resolved when handshake has happened + */ + #handshake: Promise | null = null; + + /** + * True if operation is awaiting acknowledgment + */ + #awaitingAcknowledgement = false; + + /** + * Remote operation message callback + */ + #onRemoteOperation: (op: Operation) => void; + + /** + * Handshake callback + */ + #onHandshake: (data?: EditorDocumentSerialized) => void; + + + /** + * Constructor function + * - initialises socket connection + * + * @todo think of offline situation & retries + * @todo handle close and error events + * + * @param serverAddr - address of the websocket server + * @param userId - current user identifier + * @param onHandshake - handshake callback + * @param onRemoteOperation - remote operation callback + */ + constructor(serverAddr: string, userId: string | number, onHandshake: (data?: EditorDocumentSerialized) => void, onRemoteOperation: (op: Operation) => void) { + this.#userId = userId; + this.#onRemoteOperation = onRemoteOperation; + this.#onHandshake = onHandshake; + this.#ws = new Promise(resolve => { + const ws = new WebSocket(serverAddr); + + + ws.addEventListener('open', () => { + resolve(ws); + }); + + ws.addEventListener('message', (message) => { + try { + this.#onMessage(JSON.parse(message.data) as Message); + } catch (e) { + console.error('[OTClient] Couldn\'t process a message', message.data); + } + }); + }); + } + + /** + * Sends handshake event to the server to connect the client to passed document + * + * @param documentId - document identifier + */ + public async connectDocument(documentId: DocumentId): Promise { + const ws = await this.#ws; + + this.#handshake = new Promise(resolve => { + /** + * Handles handshake response + * + * @param message - server message + */ + const onMessage = (message: MessageEvent): void => { + try { + const data = JSON.parse(message.data) as HandshakeMessage; + + if (data.type !== MessageType.Handshake) { + return; + } + + ws.removeEventListener('message', onMessage); + + this.#onHandshake(data.payload.data); + + resolve(); + } catch (e) { + console.error('[OTClient] Couldn\'t process the handshake message', message.data); + } + }; + + ws.addEventListener('message', onMessage); + }); + + ws.send(JSON.stringify({ + type: MessageType.Handshake, + payload: { + document: documentId, + userId: this.#userId, + rev: this.#rev, + } as HandshakePayload, + })); + } + + /** + * Adds operation to the pending operations array and schedule the send + * + * @param operation - operation to send + */ + public async send(operation: Operation): Promise { + await this.#handshake; + + this.#pendingOperations.push(operation); + + await this.#sendNextOperation(); + } + + /** + * Sends next operation from the pending ops array + */ + async #sendNextOperation(): Promise { + if (this.#awaitingAcknowledgement) { + return; + } + + const nextOperation = this.#pendingOperations.shift(); + + if (!nextOperation) { + this.#awaitingAcknowledgement = false; + + return; + } + + const ws = await this.#ws; + + this.#awaitingAcknowledgement = true; + + /** + * Handles acknowledgment response and sends the next operation + * + * @param message - server message + */ + const onMessage = async (message: MessageEvent): Promise => { + try { + const data = JSON.parse(message.data) as Message; + + if (data.type !== MessageType.Operation) { + return; + } + + if (data.payload.userId !== this.#userId) { + return; + } + + this.#resolvedOperations.push(nextOperation); + + + ws.removeEventListener('message', onMessage); + + this.#rev = data.payload.rev; + + this.#awaitingAcknowledgement = false; + await this.#sendNextOperation(); + } catch (e) { + console.error('[OTClient] Couldn\'t process the acknowledgement message', message.data); + } + }; + + ws.addEventListener('message', onMessage); + + ws.send(JSON.stringify({ + type: MessageType.Operation, + payload: nextOperation.serialize(), + })); + } + + + /** + * Handles remote operations from the server + * + * @param message - server message with the operation payload + */ + #onMessage(message: OperationMessage): void { + if (message.type !== MessageType.Operation) { + return; + } + + if (message.payload.userId === this.#userId) { + return; + } + + const operation = Operation.from(message.payload); + + const transformedOperation = this.#pendingOperations.reduce((result, op) => result.transform(op), operation); + + this.#rev = operation.rev!; + + this.#onRemoteOperation(transformedOperation); + } +} diff --git a/packages/collaboration-manager/src/client/index.ts b/packages/collaboration-manager/src/client/index.ts new file mode 100644 index 00000000..61991890 --- /dev/null +++ b/packages/collaboration-manager/src/client/index.ts @@ -0,0 +1,3 @@ +export * from './Message.js'; +export * from './MessageType.js'; +export * from './OTClient.js'; diff --git a/packages/collaboration-manager/src/index.ts b/packages/collaboration-manager/src/index.ts index ade2abf0..c7b8a0a2 100644 --- a/packages/collaboration-manager/src/index.ts +++ b/packages/collaboration-manager/src/index.ts @@ -1,2 +1,3 @@ export * from './CollaborationManager.js'; export * from './Operation.js'; +export * from './client/index.js'; diff --git a/packages/core/src/components/SelectionManager.ts b/packages/core/src/components/SelectionManager.ts index 5b5f7d05..0f928f94 100644 --- a/packages/core/src/components/SelectionManager.ts +++ b/packages/core/src/components/SelectionManager.ts @@ -3,11 +3,11 @@ import { FormattingAdapter } from '@editorjs/dom-adapters'; import type { CaretManagerEvents, InlineFragment, InlineToolName } from '@editorjs/model'; import { CaretManagerCaretUpdatedEvent, Index, EditorJSModel, createInlineToolData, createInlineToolName } from '@editorjs/model'; import { EventType } from '@editorjs/model'; -import { Service } from 'typedi'; import { CoreEventType, ToolLoadedCoreEvent } from './EventBus/index.js'; import { EventBus } from '@editorjs/sdk'; +import { Inject, Service } from 'typedi'; import { SelectionChangedCoreEvent } from './EventBus/core-events/SelectionChangedCoreEvent.js'; -import { InlineTool, InlineToolFormatData } from '@editorjs/sdk'; +import { type CoreConfig, InlineTool, InlineToolFormatData } from '@editorjs/sdk'; /** * SelectionManager responsible for handling selection changes and applying inline tools formatting @@ -37,15 +37,23 @@ export class SelectionManager { #inlineTools: Map = new Map(); /** + * Editor's config + */ + #config: CoreConfig; + + /** + * @param config - Editor's config * @param model - editor model instance * @param formattingAdapter - needed for applying format to the model * @param eventBus - EventBus instance to exchange events between components */ constructor( + @Inject('EditorConfig') config: CoreConfig, model: EditorJSModel, formattingAdapter: FormattingAdapter, eventBus: EventBus ) { + this.#config = config; this.#model = model; this.#formattingAdapter = formattingAdapter; this.#eventBus = eventBus; @@ -73,6 +81,10 @@ export class SelectionManager { * @param event - CaretManager event */ #handleCaretManagerUpdate(event: CaretManagerEvents): void { + if (event.detail.userId !== this.#config.userId) { + return; + } + switch (true) { case event instanceof CaretManagerCaretUpdatedEvent: { const { index: serializedIndex } = event.detail; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f6aca76..61a68f57 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ import { CollaborationManager } from '@editorjs/collaboration-manager'; -import { EditorJSModel, EventType } from '@editorjs/model'; +import { type DocumentId, EditorJSModel, EventType } from '@editorjs/model'; import type { ContainerInstance } from 'typedi'; import { Container } from 'typedi'; import { CoreEventType } from './components/EventBus/index.js'; @@ -60,6 +60,9 @@ export default class Core { */ #formattingAdapter: FormattingAdapter; + /** + * Collaboration manager + */ #collaborationManager: CollaborationManager; /** @@ -77,12 +80,18 @@ export default class Core { this.#config.userId = generateId(); } + if (this.#config.documentId === undefined) { + this.#config.documentId = generateId(); + } + this.#iocContainer.set('EditorConfig', this.#config); const eventBus = new EventBus(); + this.#iocContainer.set(EventBus, eventBus); - this.#model = new EditorJSModel(); + this.#model = new EditorJSModel(this.#config.userId, { identifier: this.#config.documentId as DocumentId }); + this.#iocContainer.set(EditorJSModel, this.#model); this.#toolsManager = this.#iocContainer.get(ToolsManager); @@ -211,7 +220,7 @@ export default class Core { /** * @todo move to "sdk" package */ -export * from './entities/index.js'; +export type * from './entities/index.js'; export * from './components/EventBus/index.js'; export * from './api/index.js'; export * from './tools/facades/index.js'; diff --git a/packages/core/src/tools/ToolsManager.ts b/packages/core/src/tools/ToolsManager.ts index 6d192e7d..7644e313 100644 --- a/packages/core/src/tools/ToolsManager.ts +++ b/packages/core/src/tools/ToolsManager.ts @@ -107,7 +107,7 @@ export default class ToolsManager { */ constructor( @Inject('EditorConfig') editorConfig: EditorConfig, - eventBus: EventBus + eventBus: EventBus ) { this.#config = this.#prepareConfig(editorConfig.tools ?? {}); this.#eventBus = eventBus; diff --git a/packages/core/src/tools/facades/BaseToolFacade.ts b/packages/core/src/tools/facades/BaseToolFacade.ts index dfd4a7d0..9ce7e918 100644 --- a/packages/core/src/tools/facades/BaseToolFacade.ts +++ b/packages/core/src/tools/facades/BaseToolFacade.ts @@ -150,7 +150,6 @@ interface ConstructorOptions { /** * Base abstract class for Tools */ -// eslint-disable-next-line @stylistic/type-generic-spacing export abstract class BaseToolFacade { /** * Tool name specified in EditorJS config diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 5c016a9f..7f71bce1 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -58,7 +58,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { /** * Editor's config */ - #config: CoreConfig; + #config: Required; /** * BlockToolAdapter constructor @@ -71,7 +71,15 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @param formattingAdapter - needed to render formatted text * @param toolName - tool name of the block */ - constructor(config: CoreConfig, model: EditorJSModel, eventBus: EventBus, caretAdapter: CaretAdapter, blockIndex: number, formattingAdapter: FormattingAdapter, toolName: string) { + constructor( + config: Required, + model: EditorJSModel, + eventBus: EventBus, + caretAdapter: CaretAdapter, + blockIndex: number, + formattingAdapter: FormattingAdapter, + toolName: string + ) { this.#config = config; this.#model = model; this.#blockIndex = blockIndex; @@ -443,7 +451,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { } } - this.#caretAdapter.updateIndex(caretIndexBuilder.build()); + this.#caretAdapter.updateIndex(caretIndexBuilder.build(), event.detail.userId); }; /** @@ -514,14 +522,14 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { input.normalize(); - this.#caretAdapter.updateIndex(builder.build()); + this.#caretAdapter.updateIndex(builder.build(), this.#config.userId); }; /** * Handles model update events and updates DOM * * @param event - model update event - * @param input - attched input element + * @param input - attached input element * @param key - data key input is attached to */ #handleModelUpdate(event: ModelEvents, input: HTMLElement, key: DataKey): void { diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index efd9f527..cd1773b4 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -1,9 +1,15 @@ -import type { Caret, EditorJSModel, CaretManagerEvents } from '@editorjs/model'; +import { isNativeInput } from '@editorjs/dom'; +import { + type Caret, + type CaretManagerEvents, + type EditorJSModel, + EventType, + Index, + IndexBuilder, + type TextRange +} from '@editorjs/model'; import type { CoreConfig } from '@editorjs/sdk'; import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset, useSelectionChange } from '../utils/index.js'; -import type { TextRange } from '@editorjs/model'; -import { EventType, IndexBuilder, Index } from '@editorjs/model'; -import { isNativeInput } from '@editorjs/dom'; /** * Caret adapter watches selection change and saves it to the model @@ -37,12 +43,17 @@ export class CaretAdapter extends EventTarget { * * @private */ - #userCaret: Caret; + #currentUserCaret: Caret; + + /** + * Map with users' carets by userId + */ + #userCarets = new Map(); /** * Editor's config */ - #config: CoreConfig; + #config: Required; /** * @class @@ -50,13 +61,14 @@ export class CaretAdapter extends EventTarget { * @param container - Editor.js DOM container * @param model - Editor.js model */ - constructor(config: CoreConfig, container: HTMLElement, model: EditorJSModel) { + constructor(config: Required, container: HTMLElement, model: EditorJSModel) { super(); this.#config = config; this.#model = model; this.#container = container; - this.#userCaret = this.#model.createCaret(this.#config.userId); + this.#currentUserCaret = this.#model.createCaret(this.#config.userId); + this.#userCarets.set(this.#config.userId, this.#currentUserCaret); const { on } = useSelectionChange(); @@ -72,7 +84,7 @@ export class CaretAdapter extends EventTarget { * Getter for internal caret index of the user */ public get userCaretIndex(): Index | null { - return this.#userCaret.index; + return this.#currentUserCaret.index; } /** @@ -89,9 +101,23 @@ export class CaretAdapter extends EventTarget { * Updates current user's caret index * * @param index - new caret index + * @param [userId] - user identifier */ - public updateIndex(index: Index | null): void { - this.#userCaret.update(index); + public updateIndex(index: Index | null, userId?: string | number): void { + if (userId === undefined) { + this.#currentUserCaret.update(index); + + return; + } + + + const caretToUpdate = this.#userCarets.get(userId); + + if (caretToUpdate === undefined) { + return; + } + + caretToUpdate.update(index); } /** @@ -105,16 +131,17 @@ export class CaretAdapter extends EventTarget { if (index !== undefined) { builder.from(index); - } else if (this.#userCaret.index !== null) { - builder.from(this.#userCaret.index); + } else if (this.#currentUserCaret.index !== null) { + builder.from(this.#currentUserCaret.index); } else { throw new Error('[CaretManager] No index provided and no user caret index found'); } /** * Inputs are stored in the hashmap with serialized index as a key - * Those keys are serialized without text range to cover the whole input, so we need to remove it here to find the input + * Those keys are serialized without document id and text range to cover the input only, so we need to remove them here to find the input */ + builder.addDocumentId(undefined); builder.addTextRange(undefined); return this.#inputs.get(builder.build().serialize()); @@ -206,9 +233,9 @@ export class CaretAdapter extends EventTarget { return; } - const caretId = event.detail.id; + const userId = event.detail.userId; - if (caretId !== this.#userCaret.id) { + if (userId !== this.#currentUserCaret.userId) { return; } diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index 0478287e..8d2edfb9 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -41,7 +41,7 @@ export class FormattingAdapter { /** * Editor's config */ - #config: CoreConfig; + #config: Required; /** * @class @@ -49,7 +49,7 @@ export class FormattingAdapter { * @param model - editor model instance * @param caretAdapter - caret adapter instance */ - constructor(config: CoreConfig, model: EditorJSModel, caretAdapter: CaretAdapter) { + constructor(config: Required, model: EditorJSModel, caretAdapter: CaretAdapter) { this.#config = config; this.#model = model; this.#caretAdapter = caretAdapter; @@ -196,7 +196,7 @@ export class FormattingAdapter { this.#rerenderRange(input, leftBoundary, rightBoundary, affectedFragments); - this.#caretAdapter.updateIndex(event.detail.index); + this.#caretAdapter.updateIndex(event.detail.index, event.detail.userId); } } diff --git a/packages/model/src/CaretManagement/Caret/Caret.spec.ts b/packages/model/src/CaretManagement/Caret/Caret.spec.ts index bf83eac5..378ee2eb 100644 --- a/packages/model/src/CaretManagement/Caret/Caret.spec.ts +++ b/packages/model/src/CaretManagement/Caret/Caret.spec.ts @@ -1,35 +1,24 @@ import { Index } from '../../entities/index.js'; import { Caret } from './Caret.js'; import { CaretEvent, CaretUpdatedEvent } from './types.js'; +import { jest } from '@jest/globals'; describe('Caret', () => { it ('should initialize with null index', () => { - const caret = new Caret(); + const caret = new Caret('user'); expect(caret.index).toBeNull(); }); it('should initialize with passed index', () => { const index = new Index(); - const caret = new Caret(index); + const caret = new Caret('user', index); expect(caret.index).toBe(index); }); - it('should generate random id', () => { - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - const randomValue = 0.5; - - jest.spyOn(Math, 'random').mockReturnValueOnce(randomValue); - - const caret = new Caret(); - - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - expect(caret.id).toBe(randomValue * 1e10); - }); - it('should update index', () => { - const caret = new Caret(); + const caret = new Caret('user'); const index = new Index(); caret.update(index); @@ -38,7 +27,7 @@ describe('Caret', () => { }); it('should dispatch updated event on index update', () => { - const caret = new Caret(); + const caret = new Caret('user'); const index = new Index(); const handler = jest.fn(); @@ -55,10 +44,10 @@ describe('Caret', () => { it('should serialize to JSON', () => { const index = new Index(); - const caret = new Caret(index); + const caret = new Caret('user', index); expect(caret.toJSON()).toEqual({ - id: caret.id, + userId: caret.userId, index: index.serialize(), }); }); diff --git a/packages/model/src/CaretManagement/Caret/Caret.ts b/packages/model/src/CaretManagement/Caret/Caret.ts index fa6b8dd7..bb8ea4cf 100644 --- a/packages/model/src/CaretManagement/Caret/Caret.ts +++ b/packages/model/src/CaretManagement/Caret/Caret.ts @@ -33,12 +33,9 @@ export class Caret extends EventBus { #index: Index | null = null; /** - * Caret id - * - * @todo maybe replace ID generation method + * User identifier */ - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - #id: number = Math.floor(Math.random() * 1e10); + #userId: string | number; /** * Caret index getter @@ -50,18 +47,20 @@ export class Caret extends EventBus { /** * Caret id getter */ - public get id(): Readonly { - return this.#id; + public get userId(): Readonly { + return this.#userId; } /** * Caret constructor * + * @param userId - user identifier * @param index - initial caret index */ - constructor(index: Index | null = null) { + constructor(userId: string | number, index: Index | null = null) { super(); + this.#userId = userId; this.#index = index; } @@ -81,7 +80,7 @@ export class Caret extends EventBus { */ public toJSON(): CaretSerialized { return { - id: this.id, + userId: this.userId, index: this.index !== null ? this.index.serialize() : null, } as CaretSerialized; } diff --git a/packages/model/src/CaretManagement/Caret/types.ts b/packages/model/src/CaretManagement/Caret/types.ts index cfa50776..2a4806b9 100644 --- a/packages/model/src/CaretManagement/Caret/types.ts +++ b/packages/model/src/CaretManagement/Caret/types.ts @@ -8,17 +8,12 @@ export interface CaretSerialized { /** * Caret id */ - readonly id: number; + readonly userId: string | number; /** * Caret index */ readonly index: string | null; - - /** - * User identifier - */ - userId?: string | number } /** diff --git a/packages/model/src/CaretManagement/CaretManager.spec.ts b/packages/model/src/CaretManagement/CaretManager.spec.ts index 3d0636f3..f4376a3a 100644 --- a/packages/model/src/CaretManagement/CaretManager.spec.ts +++ b/packages/model/src/CaretManagement/CaretManager.spec.ts @@ -7,21 +7,22 @@ import { } from '../EventBus/index.js'; import { Caret } from './Caret/index.js'; import { CaretManager } from './CaretManager.js'; +import { jest } from '@jest/globals'; describe('CaretManager', () => { it('should create new caret', () => { const manager = new CaretManager(); - const caret = manager.createCaret(); + const caret = manager.createCaret('userId'); - expect(manager.getCaret(caret.id)).toBe(caret); + expect(manager.getCaret(caret.userId)).toBe(caret); }); it('should create new caret with passed index', () => { const manager = new CaretManager(); const index = new Index(); - const caret = manager.createCaret(index); + const caret = manager.createCaret('userId', index); expect(caret.index).toBe(index); }); @@ -33,12 +34,12 @@ describe('CaretManager', () => { manager.addEventListener(EventType.CaretManagerUpdated, handler); const index = new Index(); - const caret = manager.createCaret(index); + const caret = manager.createCaret('userId', index); expect(handler).toHaveBeenCalledWith(expect.any(CaretManagerCaretAddedEvent)); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ detail: { - id: caret.id, + userId: caret.userId, index: index.serialize(), }, })); @@ -46,18 +47,18 @@ describe('CaretManager', () => { it('should update caret', () => { const manager = new CaretManager(); - const caret = manager.createCaret(); + const caret = manager.createCaret('userId'); const index = new Index(); caret.update(index); - expect(manager.getCaret(caret.id)?.index).toBe(index); + expect(manager.getCaret(caret.userId)?.index).toBe(index); }); it('should dispatch caret updated event on caret update', () => { const manager = new CaretManager(); - const caret = manager.createCaret(); + const caret = manager.createCaret('userId'); const handler = jest.fn(); manager.addEventListener(EventType.CaretManagerUpdated, handler); @@ -69,7 +70,7 @@ describe('CaretManager', () => { expect(handler).toHaveBeenCalledWith(expect.any(CaretManagerCaretUpdatedEvent)); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ detail: { - id: caret.id, + userId: caret.userId, index: index.serialize(), }, })); @@ -77,16 +78,16 @@ describe('CaretManager', () => { it('should remove caret', () => { const manager = new CaretManager(); - const caret = manager.createCaret(); + const caret = manager.createCaret('userId'); manager.removeCaret(caret); - expect(manager.getCaret(caret.id)).toBeUndefined(); + expect(manager.getCaret(caret.userId)).toBeUndefined(); }); it('should dispatch caret removed event on caret removal', () => { const manager = new CaretManager(); - const caret = manager.createCaret(); + const caret = manager.createCaret('userId'); const handler = jest.fn(); manager.addEventListener(EventType.CaretManagerUpdated, handler); @@ -96,7 +97,7 @@ describe('CaretManager', () => { expect(handler).toHaveBeenCalledWith(expect.any(CaretManagerCaretRemovedEvent)); expect(handler).toHaveBeenCalledWith(expect.objectContaining({ detail: { - id: caret.id, + userId: caret.userId, index: null, }, })); @@ -104,7 +105,7 @@ describe('CaretManager', () => { it('should not dispatch caret removed event if caret is not in the registry', () => { const manager = new CaretManager(); - const caret = new Caret(); + const caret = new Caret('userId'); const handler = jest.fn(); diff --git a/packages/model/src/CaretManagement/CaretManager.ts b/packages/model/src/CaretManagement/CaretManager.ts index 7ebbaace..fc8dc6a4 100644 --- a/packages/model/src/CaretManagement/CaretManager.ts +++ b/packages/model/src/CaretManagement/CaretManager.ts @@ -15,27 +15,28 @@ export class CaretManager extends EventBus { /** * Caret instances registry */ - #registry = new Map(); + #registry = new Map(); /** - * Returns Caret instance by id + * Returns Caret instance by userId * - * @param id - Caret id + * @param userId - identifier of a user who created the caret */ - public getCaret(id: number): Caret | undefined { - return this.#registry.get(id); + public getCaret(userId: string | number): Caret | undefined { + return this.#registry.get(userId); } /** * Creates a new Caret instance * + * @param userId - user identifier * @param [index] - initial caret index * @returns {Caret} created Caret instance */ - public createCaret(index?: Index): Caret { - const caret = new Caret(index); + public createCaret(userId: string | number, index?: Index): Caret { + const caret = new Caret(userId, index); - this.#registry.set(caret.id, caret); + this.#registry.set(caret.userId, caret); caret.addEventListener(CaretEvent.Updated, (event) => this.updateCaret(event.detail)); @@ -50,7 +51,7 @@ export class CaretManager extends EventBus { * @param caret - Caret instance to update */ public updateCaret(caret: Caret): void { - this.#registry.set(caret.id, caret); + this.#registry.set(caret.userId, caret); this.dispatchEvent(new CaretManagerCaretUpdatedEvent(caret.toJSON())); } @@ -61,7 +62,7 @@ export class CaretManager extends EventBus { * @param caret - Caret instance to remove */ public removeCaret(caret: Caret): void { - const success = this.#registry.delete(caret.id); + const success = this.#registry.delete(caret.userId); if (success) { this.dispatchEvent(new CaretManagerCaretRemovedEvent(caret.toJSON())); diff --git a/packages/model/src/EditorJSModel.integration.spec.ts b/packages/model/src/EditorJSModel.integration.spec.ts index 69dfb75d..35369038 100644 --- a/packages/model/src/EditorJSModel.integration.spec.ts +++ b/packages/model/src/EditorJSModel.integration.spec.ts @@ -8,7 +8,7 @@ describe('[Integration tests] EditorJSModel', () => { let model: EditorJSModel; beforeEach(() => { - model = new EditorJSModel(data); + model = new EditorJSModel('user', data); }); /** diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index a62fca8d..022db3e5 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -1,4 +1,8 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { beforeEach, describe } from '@jest/globals'; import { EditorJSModel } from './EditorJSModel.js'; +import { createDataKey, IndexBuilder } from './entities/index.js'; +import type { DocumentId } from './EventBus/index'; describe('EditorJSModel', () => { it('should expose only the public API', () => { @@ -33,4 +37,156 @@ describe('EditorJSModel', () => { expect(ownProperties.sort()).toEqual(allowedMethods.sort()); }); + + describe('Caret updates on remote operations', () => { + const currentUserId = 'currentUser'; + const remoteUserId = 'remoteUser'; + const documentId = 'document'; + const blocks = [ + { + name: 'paragraph', + data: { + text: { + $t: 't', + value: 'editorjs', + }, + }, + }, + { + name: 'paragraph', + data: { + text: { + $t: 't', + value: 'editorjs', + }, + }, + }, + ]; + const currentCaretIndex = new IndexBuilder() + .addDocumentId(documentId as DocumentId) + .addBlockIndex(1) + .addDataKey(createDataKey('text')) + .addTextRange([3, 3]) + .build(); + const remoteOperationIndex = new IndexBuilder() + .from(currentCaretIndex) + .addTextRange([0, 0]) + .build(); + + let model: EditorJSModel; + + beforeEach(() => { + model = new EditorJSModel(currentUserId, { + identifier: documentId, + }); + + model.initializeDocument({ blocks }); + }); + + it('should update user caret on remote text insert operation happened before caret', () => { + const caret = model.createCaret(currentUserId, currentCaretIndex); + + model.insertData(remoteUserId, remoteOperationIndex, 'a'); + + expect(caret.index!.textRange).toEqual([4, 4]); + }); + + it('should update user caret on remote text delete operation happened before caret', () => { + const caret = model.createCaret(currentUserId, currentCaretIndex); + + model.removeData(remoteUserId, remoteOperationIndex, 'e'); + + expect(caret.index!.textRange).toEqual([2, 2]); + }); + + it('should not update caret if remote operation happened after caret', () => { + const caret = model.createCaret(currentUserId, currentCaretIndex); + + model.removeData( + remoteUserId, + new IndexBuilder() + .from(remoteOperationIndex) + .addTextRange([4, 5]) + .build(), + 'e' + ); + + expect(caret.index!.textRange).toEqual([3, 3]); + }); + + it('should update user caret on remote block insert operation happened before caret', () => { + const caret = model.createCaret(currentUserId, currentCaretIndex); + + model.insertData( + remoteUserId, + new IndexBuilder() + .from(remoteOperationIndex) + .addBlockIndex(0) + .addDataKey(undefined) + .addTextRange(undefined) + .build(), + [ { + name: 'paragraph', + data: { + text: { + $t: 't', + value: '', + }, + }, + } ] + ); + + expect(caret.index!.blockIndex).toEqual(2); + }); + + it('should update user caret on remote block delete operation happened before caret', () => { + const caret = model.createCaret(currentUserId, currentCaretIndex); + + model.removeData( + remoteUserId, + new IndexBuilder() + .from(remoteOperationIndex) + .addBlockIndex(0) + .addDataKey(undefined) + .addTextRange(undefined) + .build(), + [ { + name: 'paragraph', + data: { + text: { + $t: 't', + value: '', + }, + }, + } ] + ); + + expect(caret.index!.blockIndex).toEqual(0); + }); + + it('should not update user caret on remote block operation happened after caret', () => { + const caret = model.createCaret(currentUserId, currentCaretIndex); + + model.removeData( + remoteUserId, + new IndexBuilder() + .from(remoteOperationIndex) + .addBlockIndex(1) + .addDataKey(undefined) + .addTextRange(undefined) + .build(), + [ { + name: 'paragraph', + data: { + text: { + $t: 't', + value: '', + }, + }, + } ] + ); + + expect(caret.index!.blockIndex).toEqual(1); + }); + }); }); diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index ee911d73..d5ed474c 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -1,8 +1,15 @@ // Stryker disable all -- we don't count mutation test coverage fot this file as it just proxy calls to EditorDocument /* istanbul ignore file -- we don't count test coverage fot this file as it just proxy calls to EditorDocument */ -import type { Index } from './entities/index.js'; +import { type Index, IndexBuilder } from './entities/index.js'; import { type BlockNodeSerialized, EditorDocument } from './entities/index.js'; -import { EventBus, EventType } from './EventBus/index.js'; +import { + BlockAddedEvent, + BlockRemovedEvent, EventAction, + EventBus, + EventType, + TextAddedEvent, + TextRemovedEvent +} from './EventBus/index.js'; import type { ModelEvents, CaretManagerCaretUpdatedEvent, CaretManagerEvents } from './EventBus/index.js'; import { BaseDocumentEvent, type ModifiedEventData } from './EventBus/events/BaseEvent.js'; import { getContext, WithContext } from './utils/Context.js'; @@ -38,6 +45,11 @@ export class EditorJSModel extends EventBus { */ #caretManager: CaretManager; + /** + * Current user identifier + */ + #currentUserId: string | number; + /** * Returns serialized data associated with the document * @@ -66,14 +78,16 @@ export class EditorJSModel extends EventBus { /** * Constructor for EditorJSModel class. * + * @param currentUserId - current user identifier * @param [parameters] - EditorDocument constructor arguments. * @param [parameters.children] - The child BlockNodes of the EditorDocument. * @param [parameters.properties] - The properties of the document. * @param [parameters.toolsRegistry] - ToolsRegistry instance for the current document. Defaults to a new ToolsRegistry instance. */ - constructor(...parameters: ConstructorParameters) { + constructor(currentUserId: string | number, ...parameters: ConstructorParameters) { super(); + this.#currentUserId = currentUserId; this.#document = new EditorDocument(...parameters); this.#caretManager = new CaretManager(); @@ -104,12 +118,11 @@ export class EditorJSModel extends EventBus { /** * Creates a new Caret instance in the model * - * @param _userId - user identifier which is being set to the context * @param parameters - createCaret method parameters * @param [parameters.index] - initial caret index */ @WithContext - public createCaret(_userId?: string | number, ...parameters: Parameters): ReturnType { + public createCaret(...parameters: Parameters): ReturnType { return this.#caretManager.createCaret(...parameters); } @@ -121,7 +134,7 @@ export class EditorJSModel extends EventBus { * @param parameters.caret - Caret instance to update */ @WithContext - public updateCaret(_userId?: string | number, ...parameters: Parameters): ReturnType { + public updateCaret(_userId: string | number, ...parameters: Parameters): ReturnType { console.trace(); return this.#caretManager.updateCaret(...parameters); @@ -136,7 +149,7 @@ export class EditorJSModel extends EventBus { * @param parameters.caret - Caret instance to remove */ @WithContext - public removeCaret(_userId?: string | number, ...parameters: Parameters): ReturnType { + public removeCaret(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#caretManager.removeCaret(...parameters); } @@ -161,7 +174,7 @@ export class EditorJSModel extends EventBus { * @param parameters.value - The value to update the property with */ @WithContext - public setProperty(_userId?: string | number, ...parameters: Parameters): ReturnType { + public setProperty(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.setProperty(...parameters); } @@ -176,7 +189,7 @@ export class EditorJSModel extends EventBus { * @throws Error if the index is out of bounds */ @WithContext - public addBlock(_userId?: string | number, ...parameters: Parameters): ReturnType { + public addBlock(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.addBlock(...parameters); } @@ -190,7 +203,7 @@ export class EditorJSModel extends EventBus { * @throws Error if the index is out of bounds */ @WithContext - public moveBlock(_userId?: string | number, ...parameters: Parameters): ReturnType { + public moveBlock(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.moveBlock(...parameters); } @@ -204,7 +217,7 @@ export class EditorJSModel extends EventBus { * @throws Error if the index is out of bounds */ @WithContext - public removeBlock(_userId?: string | number, ...parameters: Parameters): ReturnType { + public removeBlock(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.removeBlock(...parameters); } @@ -255,7 +268,7 @@ export class EditorJSModel extends EventBus { * @throws Error if the index is out of bounds */ @WithContext - public updateValue(_userId?: string | number, ...parameters: Parameters): ReturnType { + public updateValue(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.updateValue(...parameters); } @@ -270,7 +283,7 @@ export class EditorJSModel extends EventBus { * @throws Error if the index is out of bounds */ @WithContext - public updateTuneData(_userId?: string | number, ...parameters: Parameters): ReturnType { + public updateTuneData(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.updateTuneData(...parameters); } @@ -296,7 +309,7 @@ export class EditorJSModel extends EventBus { * @param [parameters.start] - char index where to insert text */ @WithContext - public insertText(_userId?: string | number, ...parameters: Parameters): ReturnType { + public insertText(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.insertText(...parameters); } @@ -311,7 +324,7 @@ export class EditorJSModel extends EventBus { * @param [parameters.end] - end char index of the range */ @WithContext - public removeText(_userId?: string | number, ...parameters: Parameters): ReturnType { + public removeText(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.removeText(...parameters); } @@ -328,7 +341,7 @@ export class EditorJSModel extends EventBus { * @param [parameters.data] - Inline Tool data if applicable */ @WithContext - public format(_userId?: string | number, ...parameters: Parameters): ReturnType { + public format(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.format(...parameters); } @@ -344,7 +357,7 @@ export class EditorJSModel extends EventBus { * @param parameters.end - end char index of the range */ @WithContext - public unformat(_userId?: string | number, ...parameters: Parameters): ReturnType { + public unformat(_userId: string | number, ...parameters: Parameters): ReturnType { return this.#document.unformat(...parameters); } @@ -388,13 +401,25 @@ export class EditorJSModel extends EventBus { const userId = getContext(); + if (userId !== this.#currentUserId) { + /** + * If update is made by a remote user, we might need to update current user's caret + */ + this.#updateUserCaretByRemoteChange(event); + } + + const index = new IndexBuilder() + .from(event.detail.index) + .addDocumentId(this.#document.identifier) + .build(); + /** * Here could be any logic to filter EditorDocument events; */ this.dispatchEvent( new (event.constructor as Constructor)( - event.detail.index, + index, event.detail.data, userId ) @@ -402,4 +427,61 @@ export class EditorJSModel extends EventBus { } ); } + + /** + * Update current user's caret by the model event not from the current user + * E.g. if another user inserts a character before the current user's caret, we need to update the caret + * + * @param event - model event to update caret by + */ + #updateUserCaretByRemoteChange(event: ModelEvents): void { + const userCaret = this.#caretManager.getCaret(this.#currentUserId); + + if (userCaret === undefined || userCaret.index === null) { + return; + } + + const caretIndex = userCaret.index; + + const newIndex = new IndexBuilder().from(caretIndex); + const index = event.detail.index; + + switch (true) { + case (event instanceof TextAddedEvent): + case (event instanceof TextRemovedEvent): { + if (index.blockIndex !== caretIndex.blockIndex || index.dataKey !== caretIndex.dataKey) { + return; + } + + if (index.textRange![0] > caretIndex.textRange![0]) { + return; + } + + const delta = event.detail.data.length * (event.detail.action === EventAction.Added ? 1 : -1); + + newIndex.addTextRange([caretIndex.textRange![0] + delta, caretIndex.textRange![1] + delta]); + + break; + } + + case (event instanceof BlockRemovedEvent): + case (event instanceof BlockAddedEvent): { + if (index.blockIndex! >= caretIndex.blockIndex!) { + return; + } + + /** + * @todo if removed block is the one the caret currently in — move caret to the previous block + */ + newIndex.addBlockIndex(caretIndex.blockIndex! + (event.detail.action === EventAction.Added ? 1 : -1)); + + break; + } + + default: + return; + } + + userCaret.update(newIndex.build()); + } } diff --git a/packages/model/src/EventBus/events/BaseEvent.ts b/packages/model/src/EventBus/events/BaseEvent.ts index 52fbeaf8..998aab6d 100644 --- a/packages/model/src/EventBus/events/BaseEvent.ts +++ b/packages/model/src/EventBus/events/BaseEvent.ts @@ -24,7 +24,7 @@ export interface EventPayloadBase { /** * User identifier */ - userId?: number | string; + userId: number | string; } /** @@ -47,7 +47,7 @@ export class BaseDocumentEvent exten * @param data - event data * @param userId - user identifier */ - constructor(index: Index, action: Action, data: Data, userId?: string | number) { + constructor(index: Index, action: Action, data: Data, userId: string | number) { super(EventType.Changed, { detail: { index, diff --git a/packages/model/src/EventBus/events/BlockAddedEvent.ts b/packages/model/src/EventBus/events/BlockAddedEvent.ts index 716e22a2..75406954 100644 --- a/packages/model/src/EventBus/events/BlockAddedEvent.ts +++ b/packages/model/src/EventBus/events/BlockAddedEvent.ts @@ -15,7 +15,7 @@ export class BlockAddedEvent extends BaseDocumentEvent { * CaretManagerCaretAddedEvent class constructor * * @param payload - event payload - * @param userId - user identifier */ - constructor(payload: CaretSerialized, userId?: string | number) { + constructor(payload: CaretSerialized) { // Stryker disable next-line ObjectLiteral super(EventType.CaretManagerUpdated, { - detail: { - ...payload, - userId, - }, + detail: payload, }); } } diff --git a/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts b/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts index b0596a0b..9ee6ef17 100644 --- a/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts +++ b/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts @@ -9,15 +9,11 @@ export class CaretManagerCaretRemovedEvent extends CustomEvent * CaretManagerCaretRemovedEvent class constructor * * @param payload - event payload - * @param userId - user identifier */ - constructor(payload: CaretSerialized, userId?: string | number) { + constructor(payload: CaretSerialized) { // Stryker disable next-line ObjectLiteral super(EventType.CaretManagerUpdated, { - detail: { - ...payload, - userId, - }, + detail: payload, }); } } diff --git a/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts b/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts index e2ebb942..de3996ed 100644 --- a/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts +++ b/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts @@ -9,15 +9,11 @@ export class CaretManagerCaretUpdatedEvent extends CustomEvent * CaretManagerCaretUpdatedEvent class constructor * * @param payload - event payload - * @param userId - user identifier */ - constructor(payload: CaretSerialized, userId?: string | number) { + constructor(payload: CaretSerialized) { // Stryker disable next-line ObjectLiteral super(EventType.CaretManagerUpdated, { - detail: { - ...payload, - userId, - }, + detail: payload, }); } } diff --git a/packages/model/src/EventBus/events/PropertyModifiedEvent.ts b/packages/model/src/EventBus/events/PropertyModifiedEvent.ts index a520839c..fb497588 100644 --- a/packages/model/src/EventBus/events/PropertyModifiedEvent.ts +++ b/packages/model/src/EventBus/events/PropertyModifiedEvent.ts @@ -14,7 +14,7 @@ export class PropertyModifiedEvent

extends BaseDocumentEvent, userId?: string | number) { + constructor(index: Index, data: ModifiedEventData

, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model/src/EventBus/events/TextAddedEvent.ts b/packages/model/src/EventBus/events/TextAddedEvent.ts index f3f4e1c7..b16ceffb 100644 --- a/packages/model/src/EventBus/events/TextAddedEvent.ts +++ b/packages/model/src/EventBus/events/TextAddedEvent.ts @@ -14,7 +14,7 @@ export class TextAddedEvent extends BaseDocumentEvent * @param text - added text * @param userId - user identifier */ - constructor(index: Index, text: string, userId?: string | number) { + constructor(index: Index, text: string, userId: string | number) { super(index, EventAction.Added, text, userId); } } diff --git a/packages/model/src/EventBus/events/TextFormattedEvent.ts b/packages/model/src/EventBus/events/TextFormattedEvent.ts index 4dd49180..f7b612a1 100644 --- a/packages/model/src/EventBus/events/TextFormattedEvent.ts +++ b/packages/model/src/EventBus/events/TextFormattedEvent.ts @@ -19,7 +19,7 @@ export class TextFormattedEvent extends BaseDocumentEvent extends BaseDocumentEvent, userId?: string | number) { + constructor(index: Index, data: ModifiedEventData, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model/src/EventBus/events/ValueModifiedEvent.ts b/packages/model/src/EventBus/events/ValueModifiedEvent.ts index 8b3e91e5..cf0487e4 100644 --- a/packages/model/src/EventBus/events/ValueModifiedEvent.ts +++ b/packages/model/src/EventBus/events/ValueModifiedEvent.ts @@ -14,7 +14,7 @@ export class ValueModifiedEvent extends BaseDocumentEvent, userId?: string | number) { + constructor(index: Index, data: ModifiedEventData, userId: string | number) { super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model/src/EventBus/types/indexing.ts b/packages/model/src/EventBus/types/indexing.ts index 7bc51300..a28c461f 100644 --- a/packages/model/src/EventBus/types/indexing.ts +++ b/packages/model/src/EventBus/types/indexing.ts @@ -3,7 +3,7 @@ import type { Nominal } from '../../utils/Nominal.js'; /** * Alias for a document id */ -type DocumentId = Nominal; +export type DocumentId = Nominal; /** * Numeric id for a block node diff --git a/packages/model/src/IoC/Container.spec.ts b/packages/model/src/IoC/Container.spec.ts index 1a02f6da..3e75b123 100644 --- a/packages/model/src/IoC/Container.spec.ts +++ b/packages/model/src/IoC/Container.spec.ts @@ -5,14 +5,14 @@ jest.mock('../entities/EditorDocument'); describe('IoCContainer', () => { it('should create a new container for document', function () { - const document = new EditorDocument(); + const document = new EditorDocument({ identifier: 'document' }); const container = IoCContainer.of(document); expect(container).toBeDefined(); }); it('should return an existing container for document', function () { - const document = new EditorDocument(); + const document = new EditorDocument({ identifier: 'document' }); const container = IoCContainer.of(document); expect(IoCContainer.of(document)).toBe(container); diff --git a/packages/model/src/entities/BlockNode/BlockNode.spec.ts b/packages/model/src/entities/BlockNode/BlockNode.spec.ts index ff26fcb8..32eb8661 100644 --- a/packages/model/src/entities/BlockNode/BlockNode.spec.ts +++ b/packages/model/src/entities/BlockNode/BlockNode.spec.ts @@ -1163,7 +1163,7 @@ describe('BlockNode', () => { node.addEventListener(EventType.Changed, handler); textNode.dispatchEvent(new TextAddedEvent(new IndexBuilder().addTextRange(range) - .build(), 'Hello')); + .build(), 'Hello', 'user')); expect(event).toBeInstanceOf(TextAddedEvent); expect(event) @@ -1218,7 +1218,8 @@ describe('BlockNode', () => { { value: newValue, previous: value, - } + }, + 'user' ) ); @@ -1278,7 +1279,8 @@ describe('BlockNode', () => { { value: newValue, previous: value, - } + }, + 'user' ) ); diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index ab958450..64313030 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -1,3 +1,4 @@ +import { getContext } from '../../utils/Context.js'; import type { EditorDocument } from '../EditorDocument'; import type { BlockTuneName, BlockTuneSerialized } from '../BlockTune'; import { BlockTune, createBlockTuneName } from '../BlockTune/index.js'; @@ -447,7 +448,8 @@ export class BlockNode extends EventBus { this.dispatchEvent( new ValueModifiedEvent( builder.build(), - event.detail.data + event.detail.data, + getContext()! ) ); } @@ -478,7 +480,8 @@ export class BlockNode extends EventBus { this.dispatchEvent( new TuneModifiedEvent( builder.build(), - event.detail.data + event.detail.data, + 'user' ) ); } diff --git a/packages/model/src/entities/BlockTune/index.ts b/packages/model/src/entities/BlockTune/index.ts index 91bfe627..f154c2d6 100644 --- a/packages/model/src/entities/BlockTune/index.ts +++ b/packages/model/src/entities/BlockTune/index.ts @@ -1,3 +1,4 @@ +import { getContext } from '../../utils/Context.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import type { BlockTuneConstructorParameters, BlockTuneSerialized, BlockTuneName } from './types'; import { createBlockTuneName } from './types/index.js'; @@ -52,7 +53,7 @@ export class BlockTune extends EventBus { new TuneModifiedEvent(builder.build(), { value: this.#data[key], previous: previousValue, - }) + }, getContext()!) ); } diff --git a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts index 2ececac9..fb79dea9 100644 --- a/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts +++ b/packages/model/src/entities/EditorDocument/EditorDocument.spec.ts @@ -12,6 +12,7 @@ import { TuneModifiedEvent } from '../../EventBus/events/index.js'; import { EventAction } from '../../EventBus/types/EventAction.js'; +import { jest } from '@jest/globals'; jest.mock('../BlockNode'); @@ -22,6 +23,7 @@ function createEditorDocumentWithSomeBlocks(): EditorDocument { const countOfBlocks = 3; const doc = new EditorDocument({ + identifier: 'document', properties: { readOnly: false, }, @@ -30,7 +32,12 @@ function createEditorDocumentWithSomeBlocks(): EditorDocument { const blocks = new Array(countOfBlocks).fill(undefined) .map(() => ({ name: 'header' as BlockToolName, - data: {}, + data: { + text: { + $t: 't', + value: 'some long text', + }, + }, })); doc.initialize(blocks); @@ -48,6 +55,7 @@ describe('EditorDocument', () => { // Arrange const blocksCount = 3; const document = new EditorDocument({ + identifier: 'document', properties: { readOnly: false, }, @@ -75,6 +83,7 @@ describe('EditorDocument', () => { // Arrange const blocksCount = 3; const document = new EditorDocument({ + identifier: 'document', properties: { readOnly: false, }, @@ -370,7 +379,9 @@ describe('EditorDocument', () => { it('should return the block from the specific index', () => { const countOfBlocks = 5; const blocksData = []; - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); for (let i = 0; i < countOfBlocks; i++) { const blockData = { @@ -422,6 +433,7 @@ describe('EditorDocument', () => { }; const document = new EditorDocument({ + identifier: 'document', properties: { ...properties, }, @@ -437,6 +449,7 @@ describe('EditorDocument', () => { const propertyName = 'readOnly'; const expectedValue = true; const document = new EditorDocument({ + identifier: 'document', properties: { [propertyName]: expectedValue, }, @@ -451,6 +464,7 @@ describe('EditorDocument', () => { it('should return undefined if the property does not exist', () => { const propertyName = 'readOnly'; const document = new EditorDocument({ + identifier: 'document', properties: {}, }); @@ -466,6 +480,7 @@ describe('EditorDocument', () => { const propertyName = 'readOnly'; const expectedValue = true; const document = new EditorDocument({ + identifier: 'document', properties: { [propertyName]: false, }, @@ -481,6 +496,7 @@ describe('EditorDocument', () => { const propertyName = 'readOnly'; const expectedValue = true; const document = new EditorDocument({ + identifier: 'document', properties: {}, }); @@ -533,7 +549,9 @@ describe('EditorDocument', () => { data: {}, }, ]; - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); document.initialize(blocksData); @@ -572,7 +590,9 @@ describe('EditorDocument', () => { data: {}, }, ]; - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); document.initialize(blocksData); @@ -606,7 +626,9 @@ describe('EditorDocument', () => { }); it('should throw an error if the index is out of bounds', () => { - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); const blockIndexOutOfBound = document.length + 1; const dataKey = 'data-key-1a2b' as DataKey; const expectedValue = 'new value'; @@ -638,7 +660,9 @@ describe('EditorDocument', () => { data: {}, }, ]; - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); document.initialize(blocksData); @@ -679,7 +703,9 @@ describe('EditorDocument', () => { data: {}, }, ]; - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); document.initialize(blocksData); @@ -715,7 +741,9 @@ describe('EditorDocument', () => { }); it('should throw an error if the index is out of bounds', () => { - const document = new EditorDocument(); + const document = new EditorDocument({ + identifier: 'document', + }); const blockIndexOutOfBound = document.length + 1; const tuneName = 'blockFormatting' as BlockTuneName; const updateData = { @@ -739,11 +767,16 @@ describe('EditorDocument', () => { const blockData = { name: 'text' as BlockToolName, data: { - [dataKey]: text, + [dataKey]: { + $t: 't', + value: text, + }, }, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); @@ -775,10 +808,17 @@ describe('EditorDocument', () => { beforeEach(() => { const blockData = { name: 'header' as BlockToolName, - data: {}, + data: { + [dataKey]: { + $t: 't', + value: text, + }, + }, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); @@ -823,7 +863,9 @@ describe('EditorDocument', () => { data: {}, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); @@ -865,10 +907,17 @@ describe('EditorDocument', () => { beforeEach(() => { const blockData = { name: 'header' as BlockToolName, - data: {}, + data: { + [dataKey]: { + $t: 't', + value: 'Some text', + }, + }, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); }); @@ -908,6 +957,70 @@ describe('EditorDocument', () => { }); }); + describe('.modifyData()', () => { + let document: EditorDocument; + const dataKey = 'text' as DataKey; + const blockIndex = 0; + + beforeEach(() => { + const blockData = { + name: 'header' as BlockToolName, + data: { + [dataKey]: { + $t: 't', + value: 'Some text', + }, + }, + }; + + document = new EditorDocument({ + identifier: 'document', + }); + + document.initialize([ blockData ]); + }); + + it('should call .format() method if text index and modified value provided', () => { + const spy = jest.spyOn(document, 'format'); + const rangeEnd = 5; + const index = new IndexBuilder() + .addBlockIndex(blockIndex) + .addDataKey(dataKey) + .addTextRange([0, rangeEnd]) + .build(); + + document.modifyData(index, { + value: { + tool: 'bold', + }, + previous: null, + }); + + expect(spy) + .toHaveBeenCalledWith(blockIndex, dataKey, 'bold', 0, rangeEnd); + }); + + it('should call .unformat() method if text index and previous modified value provided', () => { + const spy = jest.spyOn(document, 'unformat'); + const rangeEnd = 5; + const index = new IndexBuilder() + .addBlockIndex(blockIndex) + .addDataKey(dataKey) + .addTextRange([0, rangeEnd]) + .build(); + + document.modifyData(index, { + previous: { + tool: 'bold', + }, + value: null, + }); + + expect(spy) + .toHaveBeenCalledWith(blockIndex, dataKey, 'bold', 0, rangeEnd); + }); + }); + describe('.removeText()', () => { let document: EditorDocument; const dataKey = 'text' as DataKey; @@ -917,10 +1030,17 @@ describe('EditorDocument', () => { beforeEach(() => { const blockData = { name: 'header' as BlockToolName, - data: {}, + data: { + [dataKey]: { + $t: 't', + value: 'some long text', + }, + }, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); @@ -975,10 +1095,17 @@ describe('EditorDocument', () => { beforeEach(() => { const blockData = { name: 'header' as BlockToolName, - data: {}, + data: { + [dataKey]: { + $t: 't', + value: 'some long text', + }, + }, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); @@ -1022,10 +1149,17 @@ describe('EditorDocument', () => { beforeEach(() => { const blockData = { name: 'header' as BlockToolName, - data: {}, + data: { + [dataKey]: { + $t: 't', + value: 'some long text', + }, + }, }; - document = new EditorDocument(); + document = new EditorDocument({ + identifier: 'document', + }); document.initialize([ blockData ]); @@ -1068,6 +1202,7 @@ describe('EditorDocument', () => { readOnly: true, }; const document = new EditorDocument({ + identifier: 'document', properties, }); @@ -1102,7 +1237,8 @@ describe('EditorDocument', () => { { value: 'value', previous: 'previous', - } + }, + 'user' ) ); @@ -1128,7 +1264,8 @@ describe('EditorDocument', () => { { value: 'value', previous: 'previous', - } + }, + 'user' ) ); diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index 94630e3d..26398a29 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -1,3 +1,5 @@ +import type { DocumentId } from '../../EventBus/index'; +import { getContext } from '../../utils/Context.js'; import type { DataKey } from '../BlockNode'; import { BlockNode } from '../BlockNode/index.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; @@ -24,11 +26,18 @@ import type { Constructor } from '../../utils/types.js'; import { BaseDocumentEvent, type ModifiedEventData } from '../../EventBus/events/BaseEvent.js'; import type { Index } from '../Index/index.js'; +export * from './types/index.js'; + /** * EditorDocument class represents the top-level container for a tree-like structure of BlockNodes in an editor document. * It contains an array of BlockNodes representing the root-level nodes of the document. */ export class EditorDocument extends EventBus { + /** + * Document identifier + */ + public identifier: DocumentId; + /** * Private field representing the child BlockNodes of the EditorDocument */ @@ -44,16 +53,22 @@ export class EditorDocument extends EventBus { * * To fill the document with blocks, use the `initialize` method. * + * @todo remove tools registry? + * * @param [args] - EditorDocument constructor arguments. + * @param args.identifier - Document identifier * @param [args.properties] - The properties of the document. * @param [args.toolsRegistry] - ToolsRegistry instance for the current document. Defaults to a new ToolsRegistry instance. */ constructor({ + identifier, properties = {}, toolsRegistry = new ToolsRegistry(), - }: EditorDocumentConstructorParameters = {}) { + }: EditorDocumentConstructorParameters) { super(); + this.identifier = identifier as DocumentId; + this.#properties = properties; const container = IoCContainer.of(this); @@ -117,7 +132,7 @@ export class EditorDocument extends EventBus { builder.addBlockIndex(index); - this.dispatchEvent(new BlockAddedEvent(builder.build(), blockNode.serialized)); + this.dispatchEvent(new BlockAddedEvent(builder.build(), blockNode.serialized, getContext()!)); } /** @@ -151,7 +166,7 @@ export class EditorDocument extends EventBus { builder.addBlockIndex(index); - this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized)); + this.dispatchEvent(new BlockRemovedEvent(builder.build(), blockNode.serialized, getContext()!)); } /** @@ -206,7 +221,9 @@ export class EditorDocument extends EventBus { { value, previous: previousValue, - }) + }, + getContext()! + ) ); } @@ -318,6 +335,7 @@ export class EditorDocument extends EventBus { */ public get serialized(): EditorDocumentSerialized { return { + identifier: this.identifier, blocks: this.#children.map((block) => block.serialized), properties: this.#properties, }; @@ -416,7 +434,9 @@ export class EditorDocument extends EventBus { const builder = new IndexBuilder(); - builder.from(event.detail.index).addBlockIndex(index); + builder.from(event.detail.index) + .addDocumentId(this.identifier) + .addBlockIndex(index); this.dispatchEvent( new (event.constructor as Constructor)( diff --git a/packages/model/src/entities/EditorDocument/types/EditorDocumentConstructorParameters.ts b/packages/model/src/entities/EditorDocument/types/EditorDocumentConstructorParameters.ts index fa3814b3..1514136c 100644 --- a/packages/model/src/entities/EditorDocument/types/EditorDocumentConstructorParameters.ts +++ b/packages/model/src/entities/EditorDocument/types/EditorDocumentConstructorParameters.ts @@ -2,6 +2,11 @@ import type { Properties } from './Properties'; import type { ToolsRegistry } from '../../../tools/ToolsRegistry'; export interface EditorDocumentConstructorParameters { + /** + * Document identifier + */ + identifier: string; + /** * The properties of the document */ diff --git a/packages/model/src/entities/EditorDocument/types/EditorDocumentSerialized.ts b/packages/model/src/entities/EditorDocument/types/EditorDocumentSerialized.ts index 08f3ab0f..be29d431 100644 --- a/packages/model/src/entities/EditorDocument/types/EditorDocumentSerialized.ts +++ b/packages/model/src/entities/EditorDocument/types/EditorDocumentSerialized.ts @@ -7,6 +7,11 @@ import type { Properties } from './Properties'; * Serialized EditorDocument is a JSON object containing blocks and document properties */ export interface EditorDocumentSerialized { + /** + * Document identifier + */ + identifier: string; + /** * Array of serialized BlockNodes */ diff --git a/packages/model/src/entities/ValueNode/index.ts b/packages/model/src/entities/ValueNode/index.ts index d8c1fbaa..2d11b30a 100644 --- a/packages/model/src/entities/ValueNode/index.ts +++ b/packages/model/src/entities/ValueNode/index.ts @@ -1,3 +1,4 @@ +import { getContext } from '../../utils/Context.js'; import { IndexBuilder } from '../Index/IndexBuilder.js'; import type { ValueNodeConstructorParameters, ValueSerialized } from './types'; import { BlockChildType } from '../BlockNode/types/index.js'; @@ -44,7 +45,7 @@ export class ValueNode extends EventBus { new ValueModifiedEvent(builder.build(), { value: this.#value, previous: previousValue, - }) + }, getContext()!) ); } diff --git a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts index 07d3a61d..5824e8f5 100644 --- a/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts +++ b/packages/model/src/entities/inline-fragments/ParentInlineNode/index.ts @@ -1,3 +1,4 @@ +import { getContext } from '../../../utils/Context.js'; import { IndexBuilder } from '../../Index/IndexBuilder.js'; import type { InlineFragment, InlineNode, InlineTreeNodeSerialized } from '../InlineNode'; import type { ParentNodeConstructorOptions } from '../mixins/ParentNode'; @@ -72,7 +73,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { builder.addTextRange([index, index]); - this.dispatchEvent(new TextAddedEvent(builder.build(), text)); + this.dispatchEvent(new TextAddedEvent(builder.build(), text, getContext()!)); } /** @@ -100,7 +101,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { builder.addTextRange([start, end]); - this.dispatchEvent(new TextRemovedEvent(builder.build(), removedText)); + this.dispatchEvent(new TextRemovedEvent(builder.build(), removedText, getContext()!)); return removedText; } @@ -209,7 +210,8 @@ export class ParentInlineNode extends EventBus implements InlineNode { { tool, data, - } + }, + getContext()! ) ); @@ -251,7 +253,7 @@ export class ParentInlineNode extends EventBus implements InlineNode { builder.addTextRange([start, end]); - this.dispatchEvent(new TextUnformattedEvent(builder.build(), { tool })); + this.dispatchEvent(new TextUnformattedEvent(builder.build(), { tool }, getContext()!)); return newNodes; } diff --git a/packages/model/src/mocks/data.ts b/packages/model/src/mocks/data.ts index 0c110d7e..07a76411 100644 --- a/packages/model/src/mocks/data.ts +++ b/packages/model/src/mocks/data.ts @@ -3,6 +3,7 @@ import type { EditorDocumentSerialized } from '../entities/EditorDocument/types/index.js'; export const data: EditorDocumentSerialized = { + identifier: 'document', properties: {}, blocks: [ { diff --git a/packages/ot-server/.gitignore b/packages/ot-server/.gitignore new file mode 100644 index 00000000..854a697d --- /dev/null +++ b/packages/ot-server/.gitignore @@ -0,0 +1,24 @@ +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Swap the comments on the following lines if you don't wish to use zero-installs +# Documentation here: https://yarnpkg.com/features/zero-installs +#!.yarn/cache +#.pnp.* + +# IDE +.idea/* + +node_modules/* +dist/* + +# tests +coverage/ +reports/ + +# stryker temp files +.stryker-tmp diff --git a/packages/ot-server/README.md b/packages/ot-server/README.md new file mode 100644 index 00000000..225eecf2 --- /dev/null +++ b/packages/ot-server/README.md @@ -0,0 +1,3 @@ +# ot-server + +WebSocket server for collaborative editing purposes diff --git a/packages/ot-server/eslint.config.mjs b/packages/ot-server/eslint.config.mjs new file mode 100644 index 00000000..99a05ca5 --- /dev/null +++ b/packages/ot-server/eslint.config.mjs @@ -0,0 +1,26 @@ +import CodeX from 'eslint-config-codex'; + +export default [ + ...CodeX, + + { + /** + * Override path to the tsconfig.json file. + */ + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: './', + sourceType: 'module', + }, + }, + rules: { + 'n/no-unpublished-import': ['error', { + allowModules: [ + 'eslint-config-codex', + ], + ignoreTypeImport: true, + }], + }, + }, +]; diff --git a/packages/ot-server/jest.config.ts b/packages/ot-server/jest.config.ts new file mode 100644 index 00000000..e80b93d1 --- /dev/null +++ b/packages/ot-server/jest.config.ts @@ -0,0 +1,15 @@ +import { type JestConfigWithTsJest, createDefaultEsmPreset } from 'ts-jest'; + +export default { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: [ '/src/**/*.spec.ts' ], + modulePathIgnorePatterns: [ '/.*/__mocks__', '/.*/mocks' ], + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + ...createDefaultEsmPreset().transform, + }, +} as JestConfigWithTsJest; diff --git a/packages/ot-server/package.json b/packages/ot-server/package.json new file mode 100644 index 00000000..8fd5aa9f --- /dev/null +++ b/packages/ot-server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@editorjs/ot-server", + "packageManager": "yarn@4.0.1", + "type": "module", + "scripts": { + "build": "tsc --build tsconfig.build.json", + "dev": "yarn build --watch", + "start": "node --experimental-vm-modules dist/index.js", + "lint": "eslint ./src", + "lint:ci": "yarn lint --max-warnings 0", + "lint:fix": "yarn lint --fix", + "test": "node --experimental-vm-modules $(yarn bin jest)", + "test:coverage": "yarn test --coverage=true", + "clear": "rm -rf ./dist && rm -rf ./tsconfig.build.tsbuildinfo" + }, + "dependencies": { + "@editorjs/collaboration-manager": "workspace:^", + "@editorjs/model": "workspace:^", + "ws": "^8.18.1" + }, + "devDependencies": { + "@types/eslint": "^9.6.1", + "@types/jest": "^29.5.14", + "@types/ws": "^8.18.1", + "eslint": "^9.24.0", + "eslint-config-codex": "^2.0.3", + "jest": "^29.7.0", + "ts-node": "^10.9.2", + "typescript": "^5.8.3" + } +} diff --git a/packages/ot-server/src/DocumentManager.spec.ts b/packages/ot-server/src/DocumentManager.spec.ts new file mode 100644 index 00000000..4e1b9c35 --- /dev/null +++ b/packages/ot-server/src/DocumentManager.spec.ts @@ -0,0 +1,228 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { Operation, OperationType, type OperationTypeToData } from '@editorjs/collaboration-manager'; +import { IndexBuilder } from '@editorjs/model'; +import { DocumentManager } from './DocumentManager.js'; + +// eslint-disable-next-line jsdoc/require-param +/** + * Helper function to create an operation + */ +function createOperation( + type: T, + index: string, + data: OperationTypeToData, + userId: string | number, + rev: number +): Operation { + return new Operation( + type, + new IndexBuilder().from(JSON.stringify(index)) + .build(), + data, + userId, + rev + ); +} + +describe('DocumentManager', () => { + it('should process consequential operations', () => { + const manager = new DocumentManager('document'); + + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0', + { + payload: [{ + name: 'paragraph', + data: { + text: { + $t: 't', + value: '', + fragments: [], + }, + }, + }], + }, + 'user', + 0 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'A' }, + 'user', + 1 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'A' }, + 'user', + 2 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'A' }, + 'user', + 3 + ) + ); + + expect(manager.currentModelState()).toEqual({ + identifier: 'document', + blocks: [ + { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'AAA', + fragments: [], + }, + }, + }, + ], + properties: {}, + }); + }); + + it('should process concurrent operations', () => { + const manager = new DocumentManager('document'); + + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0', + { + payload: [{ + name: 'paragraph', + data: { + text: { + $t: 't', + value: '', + fragments: [], + }, + }, + }], + }, + 'user', + 0 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'A' }, + 'user', + 1 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'B' }, + 'user', + 1 + ) + ); + + expect(manager.currentModelState()).toEqual({ + identifier: 'document', + blocks: [ + { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'AB', + fragments: [], + }, + }, + }, + ], + properties: {}, + }); + }); + + it('should process older operations', () => { + const manager = new DocumentManager('document'); + + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0', + { + payload: [{ + name: 'paragraph', + data: { + text: { + $t: 't', + value: '', + fragments: [], + }, + }, + }], + }, + 'user', + 0 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'A' }, + 'user', + 1 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'A' }, + 'user', + 2 + ) + ); + manager.process( + createOperation( + OperationType.Insert, + 'doc@0:block@0:data@text:[0,0]', + { payload: 'B' }, + 'user', + 1 + ) + ); + + expect(manager.currentModelState()).toEqual({ + identifier: 'document', + blocks: [ + { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'AAB', + fragments: [], + }, + }, + }, + ], + properties: {}, + }); + }); +}); diff --git a/packages/ot-server/src/DocumentManager.ts b/packages/ot-server/src/DocumentManager.ts new file mode 100644 index 00000000..973465bb --- /dev/null +++ b/packages/ot-server/src/DocumentManager.ts @@ -0,0 +1,96 @@ +import { type ModifyOperationData, type Operation, OperationType } from '@editorjs/collaboration-manager'; +import { type BlockNodeSerialized, type EditorDocumentSerialized, EditorJSModel } from '@editorjs/model'; + +/** + * Class to process operations and aplly them to the document state + */ +export class DocumentManager { + /** + * Array of applied operations + */ + #operations: Operation[] = []; + + /** + * Current document revision + */ + #currentRev = 0; + + /** + * Editor model with the current document state + */ + #model: EditorJSModel; + + /** + * DocumentManager constructor function + * @param identifier - identifier of the document to manage + */ + constructor(identifier: string) { + this.#model = new EditorJSModel('server', { identifier }); + } + + /** + * Return current document revision + */ + public get currentRev(): number { + return this.#currentRev; + } + + /** + * Process new operation + * - Transform relative to operations in stack if needed + * - Puts operation to the operations array + * - Updates models state + * @todo ensure the operations are processed consequently + * @param operation - operation from the client to process + */ + public process(operation: Operation): Operation | null { + if (operation.rev! > this.#currentRev) { + console.error('Operation rejected due to incorrect revision %o', operation); + + return null; + } + + const conflictingOps = this.#operations.filter(op => op.rev! >= operation.rev!); + const transformedOp = conflictingOps.reduce((result, op) => result.transform(op), operation); + + transformedOp.rev = this.#currentRev; + + this.#currentRev += 1; + + this.#operations.push(transformedOp); + + this.#applyOperationToModel(transformedOp); + + return transformedOp; + } + + /** + * Return serialised current state of the document + */ + public currentModelState(): EditorDocumentSerialized { + return this.#model.serialized; + } + + /** + * Applies operation to the model + * @param operation - operation to apply + */ + #applyOperationToModel(operation: Operation): void { + switch (operation.type) { + case OperationType.Insert: + this.#model.insertData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + break; + case OperationType.Delete: + this.#model.removeData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + break; + case OperationType.Modify: + this.#model.modifyData(operation.userId, operation.index, { + value: operation.data.payload, + previous: (operation.data as ModifyOperationData).prevPayload, + }); + break; + default: + throw new Error('Unknown operation type'); + } + } +} diff --git a/packages/ot-server/src/OTServer.ts b/packages/ot-server/src/OTServer.ts new file mode 100644 index 00000000..d0448889 --- /dev/null +++ b/packages/ot-server/src/OTServer.ts @@ -0,0 +1,139 @@ +import type { Message } from '@editorjs/collaboration-manager'; +import { + type HandshakePayload, + MessageType, + Operation, + type SerializedOperation +} from '@editorjs/collaboration-manager'; +import type { DocumentId } from '@editorjs/model'; +import { type WebSocket, WebSocketServer } from 'ws'; +import { DocumentManager } from './DocumentManager.js'; + +const BAD_REQUEST_CODE = 4400; + +/** + * OT Server class manages client connections and ws messages processing + * @todo add tests + */ +export class OTServer { + /** + * Map of all clients grouped document id + */ + #clients: Map> = new Map(); + + /** + * Map of document managers by document id + */ + #managers: Map = new Map(); + + /** + * WebSocket server instance + */ + #wss: WebSocketServer | null = null; + + /** + * Start websocket servier + */ + public start(): void { + this.#wss = new WebSocketServer({ port: 8080 }); + + this.#wss.on('connection', ws => this.#onConnection(ws)); + } + + /** + * Connection callback + * @param ws - client websocket + */ + #onConnection(ws: WebSocket): void { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + ws.on('message', message => this.#onMessage(ws, JSON.parse(message.toString()) as Message)); + } + + /** + * Client message callback + * @param ws - client websocket + * @param message - client message + */ + #onMessage(ws: WebSocket, message: Message): void { + switch (message.type) { + case MessageType.Handshake: + this.#onHandshake(ws, message.payload as HandshakePayload); + + return; + case MessageType.Operation: + this.#onOperation(ws, message.payload as SerializedOperation); + + return; + } + } + + /** + * Handshake callback + * @param ws - client websocket + * @param payload - handshake payload + */ + #onHandshake(ws: WebSocket, payload: HandshakePayload): void { + const documentId = payload.document; + + if (documentId === undefined) { + ws.close(BAD_REQUEST_CODE, 'No document id for operation provided'); + + return; + } + + if (!this.#managers.has(documentId)) { + this.#managers.set(documentId, new DocumentManager(documentId)); + this.#clients.set(documentId, new Set()); + } + + this.#clients.get(documentId)!.add(ws); + const manager = this.#managers.get(documentId)!; + + ws.send(JSON.stringify({ + type: MessageType.Handshake, + payload: { + ...payload, + rev: manager.currentRev, + data: manager.currentModelState(), + }, + })); + } + + /** + * Client operation callback + * @param ws - client websocket + * @param payload - operation payload + */ + #onOperation(ws: WebSocket, payload: SerializedOperation): void { + const operation = Operation.from(payload); + const documentId = operation.index.documentId; + + if (!documentId) { + ws.close(BAD_REQUEST_CODE, 'No document id for operation provided'); + + return; + } + + if (!this.#managers.has(documentId)) { + ws.close(BAD_REQUEST_CODE, 'No document found for the operation'); + } + + const manager = this.#managers.get(documentId)!; + const clients = this.#clients.get(documentId)!; + + const processedOperation = manager.process(operation); + + if (processedOperation === null) { + ws.close(BAD_REQUEST_CODE, 'Operation couldn\'t be processed'); + + return; + } + + clients.forEach((client) => { + client.send(JSON.stringify({ + type: MessageType.Operation, + payload: processedOperation.serialize(), + })); + }); + } +} diff --git a/packages/ot-server/src/index.ts b/packages/ot-server/src/index.ts new file mode 100644 index 00000000..a226d565 --- /dev/null +++ b/packages/ot-server/src/index.ts @@ -0,0 +1,12 @@ +import { OTServer } from './OTServer.js'; + +/** + * main function + */ +function main(): void { + const otServer = new OTServer(); + + otServer.start(); +} + +main(); diff --git a/packages/ot-server/tsconfig.build.json b/packages/ot-server/tsconfig.build.json new file mode 100644 index 00000000..2974ea21 --- /dev/null +++ b/packages/ot-server/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "src/**/*.spec.ts" + ], + "references": [ + { + "path": "../model/tsconfig.build.json" + }, + { + "path": "../collaboration-manager/tsconfig.build.json" + } + ] +} diff --git a/packages/ot-server/tsconfig.json b/packages/ot-server/tsconfig.json new file mode 100644 index 00000000..fb5230bb --- /dev/null +++ b/packages/ot-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "types": ["jest"], + "composite": true, + "rootDir": "src/" + }, + "include": ["src/**/*"], + "exclude": [ + "node_modules/**/*", + "dist/**/*" + ] + } diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 891a54e9..916afbf7 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,4 +1,5 @@