diff --git a/packages/collaboration-manager/jest.config.ts b/packages/collaboration-manager/jest.config.ts index ad18fcf1..940d6f69 100644 --- a/packages/collaboration-manager/jest.config.ts +++ b/packages/collaboration-manager/jest.config.ts @@ -8,11 +8,12 @@ export default { tsconfig: '/tsconfig.test.json', }, }, - testMatch: [ '/src/**/*.spec.ts' ], - modulePathIgnorePatterns: [ '/.*/__mocks__', '/.*/mocks' ], + testMatch: ['/src/**/*.spec.ts'], + modulePathIgnorePatterns: ['/.*/__mocks__', '/.*/mocks'], extensionsToTreatAsEsm: ['.ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', + '^codex-tooltip$': '/test/mocks/codex-tooltip.ts', }, transform: { ...createDefaultEsmPreset().transform, diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 5e524079..61480e72 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -1,12 +1,13 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ -import { createDataKey, IndexBuilder } from '@editorjs/model'; +import { createDataKey, EventType, IndexBuilder } from '@editorjs/model'; import { EditorJSModel } from '@editorjs/model'; -import type { CoreConfig } from '@editorjs/sdk'; +import { CoreEventType, type CoreConfig } from '@editorjs/sdk'; import { beforeAll, jest } from '@jest/globals'; import { BatchedOperation } from './BatchedOperation.js'; -import { CollaborationManager } from './CollaborationManager.js'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; +import { createManager } from '../test/mocks/createManager.js'; +import { OTClient } from './client/index.js'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -31,7 +32,7 @@ describe('CollaborationManager', () => { it('should throw an error on unknown operation type', () => { const model = new EditorJSModel(userId, { identifier: documentId }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; // @ts-expect-error - for test purposes expect(() => collaborationManager.applyOperation(new Operation('unknown', new IndexBuilder().build(), 'hello'))).toThrow('Unknown operation type'); @@ -51,7 +52,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -92,7 +93,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([3, 5]) @@ -125,7 +126,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -173,7 +174,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -202,7 +203,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -249,7 +250,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -279,7 +280,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const op1 = new Operation(OperationType.Insert, new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -329,7 +330,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -382,7 +383,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -424,7 +425,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([ @@ -467,7 +468,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -510,7 +511,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -546,7 +547,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -586,7 +587,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -636,7 +637,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -689,7 +690,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -742,7 +743,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -778,7 +779,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -817,7 +818,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -859,7 +860,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [block], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -900,7 +901,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -952,7 +953,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -1005,7 +1006,7 @@ describe('CollaborationManager', () => { }, }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; model.insertText('another-user', 0, createDataKey('text'), 'hello', 0); @@ -1045,7 +1046,7 @@ describe('CollaborationManager', () => { }, }], }); - void new CollaborationManager(config as Required, model); + createManager(config as Required, model); model.insertText(userId, 0, createDataKey('text'), 'a', 0); @@ -1076,7 +1077,7 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; // Create local operation const localIndex = new IndexBuilder().addBlockIndex(0) @@ -1135,7 +1136,7 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; model.insertText(userId, 0, createDataKey('text'), 'world', 0); jest.advanceTimersByTime(500); @@ -1176,8 +1177,8 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); - const remoteCollaborationManager = new CollaborationManager(remoteConfig as Required, model); + const collaborationManager = createManager(config as Required, model).manager; + const remoteCollaborationManager = createManager(remoteConfig as Required, model).manager; // Char-by-char insert text 'hello' from local user const localText = 'hello'; @@ -1244,8 +1245,8 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); - const remoteCollaborationManager = new CollaborationManager(remoteConfig as Required, model); + const collaborationManager = createManager(config as Required, model).manager; + const remoteCollaborationManager = createManager(remoteConfig as Required, model).manager; // Isert line 'hello' from local user const localIndex = new IndexBuilder().addBlockIndex(0) @@ -1305,7 +1306,7 @@ describe('CollaborationManager', () => { }], }); - const collaborationManager = new CollaborationManager(config as Required, model); + const collaborationManager = createManager(config as Required, model).manager; // Create local delete operation const localIndex = new IndexBuilder().addBlockIndex(0) @@ -1342,4 +1343,39 @@ describe('CollaborationManager', () => { }); }); }); + + describe('destroy', () => { + it('should unsubscribe model and core event listeners', () => { + const model = new EditorJSModel(userId, { identifier: documentId }); + const removeModelListenerSpy = jest.spyOn(model, 'removeEventListener'); + const { manager, eventBus } = createManager(config as Required, model); + const removeEventBusListenerSpy = jest.spyOn(eventBus, 'removeEventListener'); + + manager.destroy(); + + expect(removeModelListenerSpy).toHaveBeenCalledWith(EventType.Changed, expect.any(Function)); + expect(removeEventBusListenerSpy).toHaveBeenCalledWith(`core:${CoreEventType.Undo}`, expect.any(Function)); + expect(removeEventBusListenerSpy).toHaveBeenCalledWith(`core:${CoreEventType.Redo}`, expect.any(Function)); + expect(removeEventBusListenerSpy).toHaveBeenCalledWith(`core:${CoreEventType.Ready}`, expect.any(Function)); + }); + + it('should close ot client connection', async () => { + const model = new EditorJSModel(userId, { identifier: documentId }); + const { manager, eventBus } = createManager({ + ...config, + collaborationServer: 'ws://test-collab.invalid/document', + } as Required, model); + + eventBus.dispatchEvent(new CustomEvent(`core:${CoreEventType.Ready}`)); + await Promise.resolve(); + + const closeSpy = jest.spyOn(OTClient.prototype, 'close'); + + manager.destroy(); + await Promise.resolve(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); + }); + }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 7dac0f42..acbfbf48 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -1,14 +1,18 @@ import { BlockAddedEvent, type BlockNodeSerialized, BlockRemovedEvent, - type EditorJSModel, - EventType, type ModelEvents, TextAddedEvent, TextFormattedEvent, TextRemovedEvent, TextUnformattedEvent } from '@editorjs/model'; -import type { CoreConfig } from '@editorjs/sdk'; +import { + CoreEventType, + type EditorAPI, + type EditorjsPlugin, + type EditorjsPluginParams, + PluginType +} from '@editorjs/sdk'; import { OTClient } from './client/index.js'; import { BatchedOperation } from './BatchedOperation.js'; import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; @@ -17,13 +21,19 @@ import { UndoRedoManager } from './UndoRedoManager.js'; const DEBOUNCE_TIMEOUT = 500; /** - * CollaborationManager listens to EditorJSModel events and applies operations + * CollaborationManager is a Plugin that listens to document API events and applies operations. + * It also manages undo/redo history and a connection to an OT server. */ -export class CollaborationManager { +export class CollaborationManager implements EditorjsPlugin { /** - * EditorJSModel instance to listen to and apply operations + * Plugin type */ - #model: EditorJSModel; + public static readonly type = PluginType.Plugin; + + /** + * Editor API instance used to interact with the document + */ + #api: EditorAPI; /** * UndoRedoManager instance to manage undo/redo operations @@ -41,11 +51,6 @@ export class CollaborationManager { */ #currentBatch: BatchedOperation | null = null; - /** - * Editor's config - */ - #config: Required; - /** * OT Client */ @@ -57,41 +62,61 @@ export class CollaborationManager { #debounceTimer?: ReturnType; /** - * Creates an instance of CollaborationManager - * @param config - Editor's config - * @param model - EditorJSModel instance to listen to and apply operations + * Cleanup callback for document updates listener */ - constructor(config: Required, model: EditorJSModel) { - this.#config = config; - this.#model = model; - this.#undoRedoManager = new UndoRedoManager(); - model.addEventListener(EventType.Changed, this.#handleEvent.bind(this)); - } + #unsubscribeDocumentUpdates?: () => void; /** - * Connects to OT server + * Cleanup callback for undo event listener */ - public connect(): void { - if (this.#config.collaborationServer === undefined) { - return; - } + #unsubscribeUndo?: () => void; - this.#client = new OTClient( - this.#config.collaborationServer, - this.#config.userId, - (data) => { - if (!data) { - return; - } + /** + * Cleanup callback for redo event listener + */ + #unsubscribeRedo?: () => void; - this.#model.initializeDocument(data); - }, - (op) => { - this.applyOperation(op); - } - ); + /** + * Cleanup callback for ready event listener + */ + #unsubscribeReady?: () => void; + + /** + * Editor's config + */ + #config: EditorjsPluginParams['config']; + + /** + * Creates an instance of CollaborationManager plugin + * @param params - plugin constructor parameters + */ + constructor(params: EditorjsPluginParams) { + const { api, config, eventBus } = params; - void this.#client.connectDocument(this.#model.serialized); + this.#api = api; + this.#config = config; + this.#undoRedoManager = new UndoRedoManager(); + + const onUndo = (): void => { + this.undo(); + }; + const onRedo = (): void => { + this.redo(); + }; + const onReady = (): void => { + this.#connect(); + }; + + this.#unsubscribeDocumentUpdates = api.document.onUpdate(this.#handleEvent.bind(this)); + + eventBus.addEventListener(`core:${CoreEventType.Undo}`, onUndo); + this.#unsubscribeUndo = () => void eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); + + eventBus.addEventListener(`core:${CoreEventType.Redo}`, onRedo); + this.#unsubscribeRedo = () => void eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); + + eventBus.addEventListener(`core:${CoreEventType.Ready}`, onReady); + this.#unsubscribeReady = () => void eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); } /** @@ -137,7 +162,7 @@ export class CollaborationManager { } /** - * Applies operation to the model + * Applies operation to the document via API * @param operation - operation to apply */ public applyOperation(operation: Operation | BatchedOperation): void { @@ -156,15 +181,27 @@ export class CollaborationManager { switch (operation.type) { case OperationType.Insert: - this.#model.insertData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#api.document.insertData({ + userId: operation.userId, + index: operation.index, + data: operation.data.payload as string | BlockNodeSerialized[], + }); break; case OperationType.Delete: - this.#model.removeData(operation.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#api.document.removeData({ + userId: operation.userId, + index: operation.index, + data: 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, + this.#api.document.modifyData({ + userId: operation.userId, + index: operation.index, + data: { + value: operation.data.payload, + previous: (operation.data as ModifyOperationData).prevPayload, + }, }); break; default: @@ -173,8 +210,8 @@ export class CollaborationManager { } /** - * Handles EditorJSModel events - * @param e - event to handle + * Handles document update events + * @param e - model event to handle */ #handleEvent(e: ModelEvents): void { let operation: Operation | null = null; @@ -271,6 +308,32 @@ export class CollaborationManager { this.#debounce(); } + /** + * Connects to the OT server if a collaboration server is configured + */ + #connect(): void { + if (this.#config.collaborationServer === undefined) { + return; + } + + this.#client = new OTClient( + this.#config.collaborationServer, + this.#config.userId, + (data) => { + if (!data) { + return; + } + + this.#api.blocks.render(data); + }, + (op) => { + this.applyOperation(op); + } + ); + + void this.#client.connectDocument(this.#api.document.data); + } + /** * Puts current batch to the undo stack and clears the batch */ @@ -292,4 +355,17 @@ export class CollaborationManager { this.#putBatchToUndo(); }, DEBOUNCE_TIMEOUT); } + + /** + * Destroys the plugin instance: clears the debounce timer + */ + public destroy(): void { + clearTimeout(this.#debounceTimer); + this.#unsubscribeDocumentUpdates?.(); + this.#unsubscribeUndo?.(); + this.#unsubscribeRedo?.(); + this.#unsubscribeReady?.(); + this.#client?.close(); + this.#client = null; + } } diff --git a/packages/collaboration-manager/src/client/OTClient.spec.ts b/packages/collaboration-manager/src/client/OTClient.spec.ts index 10c9ea77..0bce6234 100644 --- a/packages/collaboration-manager/src/client/OTClient.spec.ts +++ b/packages/collaboration-manager/src/client/OTClient.spec.ts @@ -1,94 +1,98 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ import type { DocumentId, EditorDocumentSerialized } from '@editorjs/model'; -import { createDataKey, EditorJSModel, IndexBuilder } from '@editorjs/model'; -import { beforeEach, afterEach, jest } from '@jest/globals'; -import type { CoreConfig } from '@editorjs/sdk'; -import { MessageType } from './MessageType.js'; +import { createDataKey, IndexBuilder } from '@editorjs/model'; +import { beforeEach, afterEach, jest, describe, it, expect } from '@jest/globals'; import { OTClient } from './OTClient.js'; -import { CollaborationManager } from '../CollaborationManager.js'; import { Operation, OperationType } from '../Operation.js'; +import { MessageType } from './MessageType.js'; import { MockWebSocket } from '../../test/mocks/ws.js'; const userId = 'user'; const remoteUserId = 'remote-user'; -const documentId = 'document'; - -const config: CoreConfig = { - userId, - documentId: documentId, +const documentId = 'document' as DocumentId; + +/** + * Minimal stub document used when connecting the OTClient to a document. + */ +const stubDocument: EditorDocumentSerialized = { + identifier: documentId, + blocks: [], + properties: {}, }; describe('OTClient', () => { - describe('connect (mocked WebSocket)', () => { - const collabWsUrl = 'ws://test-collab.invalid/document'; + let OriginalWebSocket: typeof WebSocket; - let OriginalWebSocket: typeof WebSocket; + beforeEach(() => { + OriginalWebSocket = globalThis.WebSocket; // eslint-disable-line no-undef + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; // eslint-disable-line no-undef + MockWebSocket.lastInstance = null; + }); - let connectDocumentSpy: jest.SpiedFunction; + afterEach(() => { + globalThis.WebSocket = OriginalWebSocket; // eslint-disable-line no-undef + MockWebSocket.lastInstance = null; + }); - let otClientFromConnect: OTClient | undefined; + describe('#onMessage / remote-operation handling', () => { + it('should call onRemoteOperation with the incoming operation when there are no pending local operations', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); - beforeEach(() => { - otClientFromConnect = undefined; - OriginalWebSocket = globalThis.WebSocket; // eslint-disable-line no-undef -- Node provides globalThis at runtime - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; // eslint-disable-line no-undef + await client.connectDocument(stubDocument); - const originalConnectDocument = OTClient.prototype.connectDocument; + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); - connectDocumentSpy = jest.spyOn(OTClient.prototype, 'connectDocument').mockImplementation(async function (this: OTClient, doc: EditorDocumentSerialized) { - otClientFromConnect = this; + const remoteOp = new Operation(OperationType.Insert, index, { payload: 'hello' }, remoteUserId, 1); - return originalConnectDocument.call(this, doc); + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: remoteOp.serialize(), }); - }); - afterEach(() => { - connectDocumentSpy.mockRestore(); - globalThis.WebSocket = OriginalWebSocket; // eslint-disable-line no-undef - MockWebSocket.lastInstance = null; + expect(onRemoteOperation).toHaveBeenCalledTimes(1); + expect(onRemoteOperation).toHaveBeenCalledWith(expect.objectContaining({ type: OperationType.Insert })); }); - it('should not apply remote operation when it transforms to Neutral against pending local operation', async () => { - jest.useRealTimers(); - - const dataKeyValueA = createDataKey('valueA'); - - const model = new EditorJSModel(userId, { identifier: documentId }); - - model.initializeDocument({ - blocks: [{ - name: 'paragraph', - data: { - text: { - value: 'a', - $t: 't', - }, - }, - }, { - name: 'paragraph', - data: { - text: { - value: 'b', - $t: 't', - }, - valueA: 0, - }, - }], - properties: {}, + it('should NOT call onRemoteOperation when the remote operation is from the current user', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); + + await client.connectDocument(stubDocument); + + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 5]) + .build(); + + // Same userId as the local user — should be ignored + const ownOp = new Operation(OperationType.Insert, index, { payload: 'hello' }, userId, 1); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Operation, + payload: ownOp.serialize(), }); - const collabConfig = { - ...config, - collaborationServer: collabWsUrl, - } as Required; + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); - const collaborationManager = new CollaborationManager(collabConfig, model); + it('should NOT call onRemoteOperation when remote operation transforms to Neutral against a pending local operation', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); - collaborationManager.connect(); + await client.connectDocument(stubDocument); const index = new IndexBuilder() - .addDocumentId(documentId as DocumentId) + .addDocumentId(documentId) .addBlockIndex(1) - .addDataKey(dataKeyValueA) + .addDataKey(createDataKey('valueA')) .build(); const localOp = new Operation(OperationType.Modify, index, { @@ -96,23 +100,15 @@ describe('OTClient', () => { prevPayload: null, }, userId); - expect(otClientFromConnect).toBeDefined(); - const otClient = otClientFromConnect!; - - /** - * Queue the same operation twice in OTClient: - * - first operation is in-flight and awaits server acknowledgement - * - second operation stays in pending operations and participates in transformation - */ - await otClient.send(localOp); - await otClient.send(Operation.from(localOp)); - - const applySpy = jest.spyOn(collaborationManager, 'applyOperation'); + // First send is in-flight (awaiting server acknowledgement). + await client.send(localOp); + // Second send stays in #pendingOperations (first is still unacknowledged). + await client.send(Operation.from(localOp)); + // Remote Modify on the same index — transforms to Neutral against the pending op. const remoteOp = new Operation(OperationType.Modify, index, { payload: { n: 2 }, prevPayload: null, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers -- test revision }, remoteUserId, 3); MockWebSocket.lastInstance!.receiveFromServer({ @@ -120,11 +116,133 @@ describe('OTClient', () => { payload: remoteOp.serialize(), }); - expect(applySpy).not.toHaveBeenCalled(); + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); + + it('should ignore messages with type other than Operation', async () => { + const onRemoteOperation = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), onRemoteOperation); + + await client.connectDocument(stubDocument); + + MockWebSocket.lastInstance!.receiveFromServer({ + type: MessageType.Handshake, + payload: { + document: documentId, + userId: remoteUserId, + rev: 0, + }, + }); + + expect(onRemoteOperation).not.toHaveBeenCalled(); + }); + }); + + describe('connectDocument', () => { + it('should send a handshake packet to the server', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + const sendSpy = jest.spyOn(MockWebSocket.prototype, 'send'); + + await client.connectDocument(stubDocument); + + const handshakeCall = sendSpy.mock.calls.find(([data]) => + typeof data === 'string' && (JSON.parse(data) as { type: MessageType }).type === MessageType.Handshake + ); + + expect(handshakeCall).toBeDefined(); + + sendSpy.mockRestore(); + }); + + it('should call onHandshake with the document data returned by the server', async () => { + const onHandshake = jest.fn(); + const client = new OTClient('ws://test-collab.invalid/document', userId, onHandshake, jest.fn()); + + await client.connectDocument(stubDocument); + + // Awaiting send() lets the handshake-reply microtask run and #handshake resolve, + // which triggers onHandshake. + const index = new IndexBuilder().addDocumentId(documentId) + .addBlockIndex(0) + .build(); + + await client.send(new Operation(OperationType.Insert, index, { payload: [] }, userId)); + + expect(onHandshake).toHaveBeenCalledTimes(1); + }); + }); + + describe('send', () => { + it('should send the operation to the server as a WebSocket message', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 0]) + .build(); + + const op = new Operation(OperationType.Insert, index, { payload: 'x' }, userId); + + await client.send(op); + + const opCall = sendSpy.mock.calls.find(([data]) => + typeof data === 'string' && (JSON.parse(data) as { type: MessageType }).type === MessageType.Operation + ); + + expect(opCall).toBeDefined(); + sendSpy.mockRestore(); + }); + + it('should queue a second operation while the first is awaiting acknowledgement', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const sendSpy = jest.spyOn(MockWebSocket.lastInstance!, 'send'); + + const index = new IndexBuilder() + .addDocumentId(documentId) + .addBlockIndex(0) + .addDataKey(createDataKey('text')) + .addTextRange([0, 0]) + .build(); + + const op1 = new Operation(OperationType.Insert, index, { payload: 'a' }, userId); + const op2 = new Operation(OperationType.Insert, index, { payload: 'b' }, userId); + + await client.send(op1); + await client.send(op2); + + // Only one Operation message should have been sent to the server (op1 is in-flight, op2 queued). + const opCalls = sendSpy.mock.calls.filter(([data]) => + typeof data === 'string' && (JSON.parse(data) as { type: MessageType }).type === MessageType.Operation + ); + + expect(opCalls).toHaveLength(1); + sendSpy.mockRestore(); + }); + }); + + describe('close', () => { + it('should close websocket connection', async () => { + const client = new OTClient('ws://test-collab.invalid/document', userId, jest.fn(), jest.fn()); + + await client.connectDocument(stubDocument); + + const closeSpy = jest.spyOn(MockWebSocket.lastInstance!, 'close'); - applySpy.mockRestore(); + client.close(); + await Promise.resolve(); - jest.useFakeTimers(); + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); }); }); }); diff --git a/packages/collaboration-manager/src/client/OTClient.ts b/packages/collaboration-manager/src/client/OTClient.ts index 3e5d394d..7f04408b 100644 --- a/packages/collaboration-manager/src/client/OTClient.ts +++ b/packages/collaboration-manager/src/client/OTClient.ts @@ -139,6 +139,15 @@ export class OTClient { await this.#sendNextOperation(); } + /** + * Closes websocket connection + */ + public close(): void { + void this.#ws.then((ws) => { + ws.close(); + }); + } + /** * Sends next operation from the pending ops array */ diff --git a/packages/collaboration-manager/test/mocks/codex-tooltip.ts b/packages/collaboration-manager/test/mocks/codex-tooltip.ts new file mode 100644 index 00000000..c5419340 --- /dev/null +++ b/packages/collaboration-manager/test/mocks/codex-tooltip.ts @@ -0,0 +1,13 @@ +/** + * Minimal mock for codex-tooltip to avoid `window is not defined` in Jest (Node) environment. + */ +export default class Tooltip { + // eslint-disable-next-line @typescript-eslint/no-empty-function + public show(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public hide(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public onHover(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public destroy(): void {} +} diff --git a/packages/collaboration-manager/test/mocks/createManager.ts b/packages/collaboration-manager/test/mocks/createManager.ts new file mode 100644 index 00000000..b018faf1 --- /dev/null +++ b/packages/collaboration-manager/test/mocks/createManager.ts @@ -0,0 +1,66 @@ +import type { EditorDocumentSerialized, ModelEvents } from '@editorjs/model'; +import { EventType } from '@editorjs/model'; +import type { EditorJSModel } from '@editorjs/model'; +import { EventBus } from '@editorjs/sdk'; +import type { CoreConfigValidated, DocumentAPI, EditorAPI, InsertRemoveDataParams, ModifyDataParams } from '@editorjs/sdk'; +import { CollaborationManager } from '../../src/CollaborationManager.js'; + +/** + * Creates a mock DocumentAPI backed by a real EditorJSModel instance + * @param model - the EditorJS model to back the mock API with + */ +function createMockDocumentAPI(model: EditorJSModel): DocumentAPI { + return { + get data(): EditorDocumentSerialized { + return model.serialized; + }, + onUpdate(callback: (event: ModelEvents) => void): () => void { + model.addEventListener(EventType.Changed, callback); + + return () => model.removeEventListener(EventType.Changed, callback); + }, + insertData({ userId, index, data }: InsertRemoveDataParams): void { + model.insertData(userId, index, data); + }, + removeData({ userId, index, data }: InsertRemoveDataParams): void { + model.removeData(userId, index, data); + }, + modifyData({ userId, index, data }: ModifyDataParams): void { + model.modifyData(userId, index, data); + }, + }; +} + +/** + * Creates a CollaborationManager instance backed by a real model for testing + * @param config - editor configuration + * @param model - the EditorJS model instance + * @returns an object containing the manager and the eventBus used + */ +export function createManager(config: CoreConfigValidated, model: EditorJSModel): { manager: CollaborationManager; + eventBus: EventBus; } { + const eventBus = new EventBus(); + + const api: EditorAPI = { + document: createMockDocumentAPI(model), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + blocks: { + render: () => undefined, + } as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + selection: {} as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + text: {} as any, + }; + + const manager = new CollaborationManager({ + config, + api, + eventBus, + }); + + return { + manager, + eventBus, + }; +} diff --git a/packages/collaboration-manager/test/mocks/ws.ts b/packages/collaboration-manager/test/mocks/ws.ts index a4543919..40e2d38c 100644 --- a/packages/collaboration-manager/test/mocks/ws.ts +++ b/packages/collaboration-manager/test/mocks/ws.ts @@ -1,4 +1,4 @@ -import { EditorDocumentSerialized } from '@editorjs/model'; +import type { EditorDocumentSerialized } from '@editorjs/model'; import type { HandshakePayload } from '../../src/client/Message.js'; import { MessageType } from '../../src/client/MessageType.js'; @@ -31,7 +31,6 @@ export class MockWebSocket { /** * Adds event listener - * * @param type - event type * @param listener - listener function */ @@ -52,7 +51,6 @@ export class MockWebSocket { /** * Removes event listener - * * @param type - event type * @param listener - listener function */ @@ -66,12 +64,18 @@ export class MockWebSocket { /** * Sends data to the server - * * @param data - data to send */ public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { - const raw = typeof data === 'string' ? data : String(data); - const message = JSON.parse(raw) as { type: string; payload: { document?: string; data?: EditorDocumentSerialized } }; + // eslint-disable-next-line @typescript-eslint/no-base-to-string + const raw = typeof data === 'string' ? data : data.toString(); + const message = JSON.parse(raw) as { + type: MessageType; + payload: { + document?: string; + data?: EditorDocumentSerialized; + }; + }; if (message.type === MessageType.Handshake) { const handshakePayload = message.payload as HandshakePayload; @@ -93,9 +97,15 @@ export class MockWebSocket { } } + /** + * Closes websocket connection + */ + public close(): void { + this.readyState = 3; + } + /** * Deliver a server → client WebSocket payload (remote operation, etc.). - * * @param payload - payload to receive from the server */ public receiveFromServer(payload: unknown): void { @@ -104,12 +114,11 @@ export class MockWebSocket { /** * Emits event - * * @param type - event type * @param event - event object */ #emit(type: string, event: { data: string }): void { - this.listeners.get(type)?.forEach(fn => { + this.listeners.get(type)?.forEach((fn) => { fn(event); }); } diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts index 943dea0f..6182eeb4 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { beforeEach, describe, expect, jest } from '@jest/globals'; +import type { CoreConfigValidated } from '@editorjs/sdk'; jest.unstable_mockModule('@editorjs/model', () => { const EditorJSModel = jest.fn(() => ({ @@ -21,7 +22,7 @@ describe('DocumentAPI', () => { // @ts-expect-error - mock object, don't need to pass any arguments const model = new EditorJSModel(); - const documentAPI = new DocumentAPI(model); + const documentAPI = new DocumentAPI(model, {} as unknown as CoreConfigValidated); beforeEach(() => { jest.resetAllMocks(); diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.ts index a3151501..777559c8 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.ts @@ -1,8 +1,14 @@ import 'reflect-metadata'; -import { type EditorDocumentSerialized, EditorJSModel, EventType, ModelEvents } from '@editorjs/model'; -import { DocumentAPI as DocumentApiInterface } from '@editorjs/sdk'; -import { injectable } from 'inversify'; +import { type EditorDocumentSerialized, EditorJSModel, EventType, type ModelEvents } from '@editorjs/model'; +import { + CoreConfigValidated, + DocumentAPI as DocumentApiInterface, + type InsertRemoveDataParams, + type ModifyDataParams +} from '@editorjs/sdk'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../../tokens.js'; /** * Document API @@ -15,13 +21,23 @@ export class DocumentAPI implements DocumentApiInterface { */ #model: EditorJSModel; + /** + * Editor's config + */ + #config: CoreConfigValidated; + /** * DocumentAPI constructor * All parameters are injected through the IoC container * @param model - Editor's Document Model instance + * @param config - Editor's config */ - constructor(model: EditorJSModel) { + constructor( + model: EditorJSModel, + @inject(TOKENS.EditorConfig) config: CoreConfigValidated + ) { this.#model = model; + this.#config = config; } /** @@ -42,4 +58,37 @@ export class DocumentAPI implements DocumentApiInterface { this.#model.removeEventListener(EventType.Changed, callback); }; } + + /** + * Inserts data at the specified index + * @param params - insert data method params + * @param [params.userId] - user identifier attributed to the change + * @param params.index - position in the document tree where data should be inserted + * @param params.data - text or blocks to insert + */ + public insertData({ userId = this.#config.userId, index, data }: InsertRemoveDataParams): void { + this.#model.insertData(userId, index, data); + } + + /** + * Removes data at the specified index + * @param params - remove data method params + * @param [params.userId] - user identifier attributed to the change + * @param params.index - Index of the document node to remove + * @param params.data - removed data + */ + public removeData({ userId = this.#config.userId, index, data }: InsertRemoveDataParams): void { + this.#model.removeData(userId, index, data); + } + + /** + * Modifies data at the specified index + * @param params - modify data method params + * @param [params.userId] - user identifier attributed to the change + * @param params.index - Index of the document node to modify + * @param params.data - modification data containing current and previous values + */ + public modifyData({ userId = this.#config.userId, index, data }: ModifyDataParams): void { + this.#model.modifyData(userId, index, data); + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a28bb6c4..7c16d0c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ import { Container } from 'inversify'; import { type BlockToolConstructor, CoreEventType, + CoreEventBase, EventBus, type InlineToolConstructor, PluginType, @@ -28,7 +29,7 @@ import { TOKENS } from './tokens.js'; const DEFAULT_HOLDER_ID = 'editorjs'; /** - * Editor entry poit + * Editor entry point * - initializes Model * - subscribes to model updates * - creates Adapters for Tools @@ -60,11 +61,6 @@ export default class Core { */ #plugins: Container; - /** - * Collaboration manager - */ - #collaborationManager: CollaborationManager; - /** * @param config - Editor configuration */ @@ -97,29 +93,18 @@ export default class Core { this.#toolsManager = this.#iocContainer.get(ToolsManager); - this.#collaborationManager = new CollaborationManager(this.#config, this.#model); - - this.#iocContainer.bind(CollaborationManager).toConstantValue(this.#collaborationManager); - if (config.onModelUpdate !== undefined) { this.#model.addEventListener(EventType.Changed, () => { config.onModelUpdate?.(this.#model); }); } - eventBus.addEventListener(`core:${CoreEventType.Undo}`, () => { - this.#collaborationManager.undo(); - }); - - eventBus.addEventListener(`core:${CoreEventType.Redo}`, () => { - this.#collaborationManager.redo(); - }); - this.use(Paragraph); this.use(BoldInlineTool); this.use(ItalicInlineTool); this.use(LinkInlineTool); this.use(ShortcutsPlugin); + this.use(CollaborationManager); this.use(DOMAdapters); } @@ -183,7 +168,10 @@ export default class Core { this.#iocContainer.get(BlockRenderer); this.#model.initializeDocument({ blocks }); - this.#collaborationManager.connect(); + + const eventBus = this.#iocContainer.get(EventBus); + + eventBus.dispatchEvent(new CoreEventBase(CoreEventType.Ready, undefined)); } catch (error) { console.error('Editor.js initialization failed', error); } diff --git a/packages/ot-server/jest.config.ts b/packages/ot-server/jest.config.ts index 184ce25f..0a49adc9 100644 --- a/packages/ot-server/jest.config.ts +++ b/packages/ot-server/jest.config.ts @@ -9,6 +9,7 @@ export default { moduleNameMapper: { // eslint-disable-next-line @typescript-eslint/naming-convention '^(\\.{1,2}/.*)\\.js$': '$1', + '^codex-tooltip$': '/test/mocks/codex-tooltip.ts', }, transform: { ...createDefaultEsmPreset().transform, diff --git a/packages/ot-server/test/mocks/codex-tooltip.ts b/packages/ot-server/test/mocks/codex-tooltip.ts new file mode 100644 index 00000000..c5419340 --- /dev/null +++ b/packages/ot-server/test/mocks/codex-tooltip.ts @@ -0,0 +1,13 @@ +/** + * Minimal mock for codex-tooltip to avoid `window is not defined` in Jest (Node) environment. + */ +export default class Tooltip { + // eslint-disable-next-line @typescript-eslint/no-empty-function + public show(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public hide(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public onHover(): void {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + public destroy(): void {} +} diff --git a/packages/sdk/src/api/DocumentAPI.ts b/packages/sdk/src/api/DocumentAPI.ts index 6c26bbf2..2d783f23 100644 --- a/packages/sdk/src/api/DocumentAPI.ts +++ b/packages/sdk/src/api/DocumentAPI.ts @@ -1,4 +1,28 @@ -import type { EditorDocumentSerialized, ModelEvents } from '@editorjs/model'; +import type { BlockNodeSerialized, EditorDocumentSerialized, Index, ModelEvents, ModifiedEventData } from '@editorjs/model'; + +/** + * Parameters for insertData and removeData methods + */ +export interface InsertRemoveDataParams { + /** User identifier attributed to the change */ + userId: string | number | undefined; + /** Position in the document tree where data should be inserted or removed */ + index: Index; + /** Text or blocks to insert or remove */ + data: string | BlockNodeSerialized[]; +} + +/** + * Parameters for modifyData method + */ +export interface ModifyDataParams { + /** User identifier attributed to the change */ + userId: string | number | undefined; + /** Position in the document tree where data should be modified */ + index: Index; + /** Modification data containing current and previous values */ + data: ModifiedEventData; +} /** * Document API interface @@ -15,4 +39,22 @@ export interface DocumentAPI { * @param callback - callback called on model update */ onUpdate(callback: (event: ModelEvents) => void): () => void; + + /** + * Inserts data at the specified index + * @param params - insert operation parameters + */ + insertData(params: InsertRemoveDataParams): void; + + /** + * Removes data at the specified index + * @param params - remove operation parameters + */ + removeData(params: InsertRemoveDataParams): void; + + /** + * Modifies data at the specified index + * @param params - modify operation parameters + */ + modifyData(params: ModifyDataParams): void; } diff --git a/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts b/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts index 8f7995b5..403e3c81 100644 --- a/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts +++ b/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts @@ -33,5 +33,10 @@ export enum CoreEventType { /** * Event is fired when redo action should be performed */ - Redo = 'redo' + Redo = 'redo', + + /** + * Event is fired when the Editor is fully initialized (document and tools are ready) + */ + Ready = 'ready' }