diff --git a/.github/workflows/collaboration-manager.yml b/.github/workflows/collaboration-manager.yml index 7fec28ae..82af2c4a 100644 --- a/.github/workflows/collaboration-manager.yml +++ b/.github/workflows/collaboration-manager.yml @@ -14,7 +14,7 @@ jobs: - name: Build the package uses: ./.github/actions/build with: - package-name: '@editorjs/model' + package-name: '@editorjs/collaboration-manager' - name: Run ESLint check uses: ./.github/actions/lint @@ -31,7 +31,7 @@ jobs: - name: Build the package uses: ./.github/actions/build with: - package-name: '@editorjs/model' + package-name: '@editorjs/collaboration-manager' - name: Run unit tests uses: ./.github/actions/unit-tests diff --git a/packages/collaboration-manager/package.json b/packages/collaboration-manager/package.json index 09e375d0..87ab2757 100644 --- a/packages/collaboration-manager/package.json +++ b/packages/collaboration-manager/package.json @@ -17,7 +17,8 @@ "clear": "rm -rf ./dist && rm -rf ./tsconfig.build.tsbuildinfo" }, "dependencies": { - "@editorjs/model": "workspace:^" + "@editorjs/model": "workspace:^", + "@editorjs/sdk": "workspace:^" }, "devDependencies": { "@jest/globals": "^29.7.0", diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 25b8974b..18ea9fb6 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -1,16 +1,23 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import { createDataKey, IndexBuilder } from '@editorjs/model'; import { EditorJSModel } from '@editorjs/model'; +import type { CoreConfig } from '@editorjs/sdk'; import { beforeAll, jest } from '@jest/globals'; import { CollaborationManager } from './CollaborationManager.js'; import { Operation, OperationType } from './Operation.js'; +const config: Partial = { userId: 'user' }; + describe('CollaborationManager', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + describe('applyOperation', () => { it('should throw an error on unknown operation type', () => { const model = new EditorJSModel(); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); // @ts-expect-error - for test purposes expect(() => collaborationManager.applyOperation(new Operation('unknown', new IndexBuilder().build(), 'hello'))).toThrow('Unknown operation type'); @@ -30,7 +37,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -70,7 +77,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([ @@ -103,7 +110,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -150,7 +157,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -178,7 +185,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -228,7 +235,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -280,7 +287,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -321,7 +328,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([ @@ -363,7 +370,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -405,7 +412,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 4]) @@ -440,7 +447,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Insert, index, { @@ -479,7 +486,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 5]) @@ -528,7 +535,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -580,7 +587,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 3]) @@ -632,7 +639,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -667,7 +674,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -705,7 +712,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -747,7 +754,7 @@ describe('CollaborationManager', () => { model.initializeDocument({ blocks: [ block ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index = new IndexBuilder().addBlockIndex(0) .build(); const operation = new Operation(OperationType.Delete, index, { @@ -787,7 +794,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -838,7 +845,7 @@ describe('CollaborationManager', () => { }, } ], }); - const collaborationManager = new CollaborationManager(model); + const collaborationManager = new CollaborationManager(config, model); const index1 = new IndexBuilder().addBlockIndex(0) .addDataKey(createDataKey('text')) .addTextRange([0, 0]) @@ -875,4 +882,40 @@ describe('CollaborationManager', () => { properties: {}, }); }); + + it('should not undo operations from not a current user', () => { + const model = new EditorJSModel(); + + model.initializeDocument({ + blocks: [ { + name: 'paragraph', + data: { + text: { + value: '', + $t: 't', + }, + }, + } ], + }); + const collaborationManager = new CollaborationManager(config, model); + + model.insertText('another-user', 0, createDataKey('text'), 'hello', 0); + + collaborationManager.undo(); + + expect(model.serialized).toStrictEqual({ + blocks: [ { + name: 'paragraph', + tunes: {}, + data: { + text: { + $t: 't', + value: 'hello', + fragments: [], + }, + }, + } ], + properties: {}, + }); + }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 3ab79ead..6b7d9874 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -8,6 +8,7 @@ import { TextFormattedEvent, TextRemovedEvent, TextUnformattedEvent } from '@editorjs/model'; +import type { CoreConfig } from '@editorjs/sdk'; import { OperationsBatch } from './OperationsBatch.js'; import { type ModifyOperationData, Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; @@ -31,14 +32,24 @@ export class CollaborationManager { */ #shouldHandleEvents = true; + /** + * Current operations batch + */ #currentBatch: OperationsBatch | null = null; + /** + * Editor's config + */ + #config: CoreConfig; + /** * Creates an instance of CollaborationManager * + * @param config - Editor's config * @param model - EditorJSModel instance to listen to and apply operations */ - constructor(model: EditorJSModel) { + constructor(config: CoreConfig, model: EditorJSModel) { + this.#config = config; this.#model = model; this.#undoRedoManager = new UndoRedoManager(); model.addEventListener(EventType.Changed, this.#handleEvent.bind(this)); @@ -94,13 +105,13 @@ export class CollaborationManager { public applyOperation(operation: Operation): void { switch (operation.type) { case OperationType.Insert: - this.#model.insertData(operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#model.insertData(this.#config.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); break; case OperationType.Delete: - this.#model.removeData(operation.index, operation.data.payload as string | BlockNodeSerialized[]); + this.#model.removeData(this.#config.userId, operation.index, operation.data.payload as string | BlockNodeSerialized[]); break; case OperationType.Modify: - this.#model.modifyData(operation.index, { + this.#model.modifyData(this.#config.userId, operation.index, { value: operation.data.payload, previous: (operation.data as ModifyOperationData).prevPayload, }); @@ -119,8 +130,10 @@ export class CollaborationManager { if (!this.#shouldHandleEvents) { return; } + let operation: Operation | null = null; + /** * @todo add all model events */ @@ -128,34 +141,34 @@ export class CollaborationManager { case (e instanceof TextAddedEvent): operation = new Operation(OperationType.Insert, e.detail.index, { payload: e.detail.data, - }); + }, e.detail.userId); break; case (e instanceof TextRemovedEvent): operation = new Operation(OperationType.Delete, e.detail.index, { payload: e.detail.data, - }); + }, e.detail.userId); break; case (e instanceof TextFormattedEvent): operation = new Operation(OperationType.Modify, e.detail.index, { payload: e.detail.data, prevPayload: null, - }); + }, e.detail.userId); break; case (e instanceof TextUnformattedEvent): operation = new Operation(OperationType.Modify, e.detail.index, { prevPayload: e.detail.data, payload: null, - }); + }, e.detail.userId); break; case (e instanceof BlockAddedEvent): operation = new Operation(OperationType.Insert, e.detail.index, { payload: [ e.detail.data ], - }); + }, e.detail.userId); break; case (e instanceof BlockRemovedEvent): operation = new Operation(OperationType.Delete, e.detail.index, { payload: [ e.detail.data ], - }); + }, e.detail.userId); break; // Stryker disable next-line ConditionalExpression default: @@ -167,6 +180,11 @@ export class CollaborationManager { return; } + if (e.detail.userId !== this.#config.userId) { + return; + } + + const onBatchTermination = (batch: OperationsBatch, lastOp?: Operation): void => { const effectiveOp = batch.getEffectiveOperation(); diff --git a/packages/collaboration-manager/src/Operation.ts b/packages/collaboration-manager/src/Operation.ts index 8cb66731..69b12cf9 100644 --- a/packages/collaboration-manager/src/Operation.ts +++ b/packages/collaboration-manager/src/Operation.ts @@ -68,17 +68,24 @@ export class Operation { */ public data: OperationTypeToData; + /** + * Identifier of a user who created an operation; + */ + public userId?: string | number; + /** * Creates an instance of Operation * * @param type - operation type * @param index - index in the document model tree * @param data - operation data + * @param userId - user identifier */ - constructor(type: T, index: Index, data: OperationTypeToData) { + constructor(type: T, index: Index, data: OperationTypeToData, userId?: string | number) { this.type = type; this.index = index; this.data = data; + this.userId = userId; } /** diff --git a/packages/collaboration-manager/tsconfig.build.json b/packages/collaboration-manager/tsconfig.build.json index d59c527a..c53706de 100644 --- a/packages/collaboration-manager/tsconfig.build.json +++ b/packages/collaboration-manager/tsconfig.build.json @@ -13,6 +13,9 @@ "references": [ { "path": "../model/tsconfig.build.json" + }, + { + "path": "../sdk/tsconfig.build.json" } ] } diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 260972ac..316dfd71 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -122,7 +122,7 @@ export class BlocksManager { } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - this.#model.addBlock({ + this.#model.addBlock(this.#config.userId, { ...data, name: type, }, index); @@ -162,6 +162,7 @@ export class BlocksManager { const toolName = event.detail.data.name; const blockToolAdapter = new BlockToolAdapter( + this.#config, this.#model, this.#caretAdapter, index.blockIndex, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ed2e5c61..e7ca6040 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ import { SelectionManager } from './components/SelectionManager.js'; import type { EditorjsPluginConstructor } from './entities/EditorjsPlugin.js'; import { EditorAPI } from './api/index.js'; import { UiComponentType } from './entities/Ui.js'; +import { generateId } from './utils/uid.js'; /** * If no holder is provided via config, the editor will be appended to the element with this id @@ -72,6 +73,10 @@ export default class Core { this.#config = config as CoreConfigValidated; + if (this.#config.userId === undefined) { + this.#config.userId = generateId(); + } + this.#iocContainer.set('EditorConfig', this.#config); this.#model = new EditorJSModel(); @@ -79,14 +84,14 @@ export default class Core { this.#toolsManager = this.#iocContainer.get(ToolsManager); - this.#caretAdapter = new CaretAdapter(this.#config.holder, this.#model); + this.#caretAdapter = new CaretAdapter(this.#config, this.#config.holder, this.#model); this.#iocContainer.set(CaretAdapter, this.#caretAdapter); - this.#collaborationManager = new CollaborationManager(this.#model); + this.#collaborationManager = new CollaborationManager(this.#config, this.#model); this.#iocContainer.set(CollaborationManager, this.#collaborationManager); - this.#formattingAdapter = new FormattingAdapter(this.#model, this.#caretAdapter); + this.#formattingAdapter = new FormattingAdapter(this.#config, this.#model, this.#caretAdapter); this.#iocContainer.set(FormattingAdapter, this.#formattingAdapter); this.#iocContainer.get(SelectionManager); diff --git a/packages/core/src/utils/uid.ts b/packages/core/src/utils/uid.ts new file mode 100644 index 00000000..a08bc371 --- /dev/null +++ b/packages/core/src/utils/uid.ts @@ -0,0 +1,7 @@ +/** + * Generates unique UUID + */ +export function generateId(): string { + // eslint-disable-next-line n/no-unsupported-features/node-builtins + return crypto.randomUUID(); +} diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json index 9bcd789e..2fc407f5 100644 --- a/packages/core/tsconfig.build.json +++ b/packages/core/tsconfig.build.json @@ -12,6 +12,9 @@ }, { "path": "../dom-adapters/tsconfig.build.json" + }, + { + "path": "../collaboration-manager/tsconfig.build.json" } ] } diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 79d7dd5d..dc96b238 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -20,7 +20,7 @@ import { isNonTextInput } from '../utils/index.js'; import { InputType } from './types/InputType.js'; -import type { BlockToolAdapter as BlockToolAdapterInterface } from '@editorjs/sdk'; +import type { BlockToolAdapter as BlockToolAdapterInterface, CoreConfig } from '@editorjs/sdk'; import type { FormattingAdapter } from '../FormattingAdapter/index.js'; /** @@ -54,16 +54,23 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ #toolName: string; + /** + * Editor's config + */ + #config: CoreConfig; + /** * BlockToolAdapter constructor * + * @param config - Editor's config * @param model - EditorJSModel instance * @param caretAdapter - CaretAdapter instance * @param blockIndex - index of the block that this adapter is connected to * @param formattingAdapter - needed to render formatted text * @param toolName - tool name of the block */ - constructor(model: EditorJSModel, caretAdapter: CaretAdapter, blockIndex: number, formattingAdapter: FormattingAdapter, toolName: string) { + constructor(config: CoreConfig, model: EditorJSModel, caretAdapter: CaretAdapter, blockIndex: number, formattingAdapter: FormattingAdapter, toolName: string) { + this.#config = config; this.#model = model; this.#blockIndex = blockIndex; this.#caretAdapter = caretAdapter; @@ -134,7 +141,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * If selection is not collapsed, just remove selected text */ if (start !== end) { - this.#model.removeText(this.#blockIndex, key, start, end); + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); return; } @@ -196,7 +203,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { */ } - this.#model.removeText(this.#blockIndex, key, start, end); + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); }; /** @@ -213,7 +220,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { const start: number = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); const end: number = getAbsoluteRangeOffset(input, range.endContainer, range.endOffset); - this.#model.removeText(this.#blockIndex, key, start, end); + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); }; /** @@ -254,7 +261,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { case InputType.InsertFromDrop: case InputType.InsertFromPaste: { if (start !== end) { - this.#model.removeText(this.#blockIndex, key, start, end); + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); } let data: string; @@ -271,7 +278,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { data = event.dataTransfer!.getData('text/plain'); } - this.#model.insertText(this.#blockIndex, key, data, start); + this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); break; } @@ -285,12 +292,12 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * it means that user selected some text and replaced it with new one */ if (start !== end) { - this.#model.removeText(this.#blockIndex, key, start, end); + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, end); } const data = event.data as string; - this.#model.insertText(this.#blockIndex, key, data, start); + this.#model.insertText(this.#config.userId, this.#blockIndex, key, data, start); break; } @@ -322,7 +329,7 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { * @todo Think if we need to keep that or not */ if (isInputNative === true) { - this.#model.insertText(this.#blockIndex, key, '\n', start); + this.#model.insertText(this.#config.userId, this.#blockIndex, key, '\n', start); } break; default: @@ -342,17 +349,21 @@ export class BlockToolAdapter implements BlockToolAdapterInterface { const currentValue = this.#model.getText(this.#blockIndex, key); const newValueAfter = currentValue.slice(end); - this.#model.removeText(this.#blockIndex, key, start, currentValue.length); - this.#model.addBlock({ - name: this.#toolName, - data : { - [key]: { - $t: 't', - value: newValueAfter, - fragments: [], + this.#model.removeText(this.#config.userId, this.#blockIndex, key, start, currentValue.length); + this.#model.addBlock( + this.#config.userId, + { + name: this.#toolName, + data : { + [key]: { + $t: 't', + value: newValueAfter, + fragments: [], + }, }, }, - }, this.#blockIndex + 1); + this.#blockIndex + 1 + ); /** * Raf is needed to ensure that the new block is added so caret can be moved to it diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index 327694e6..29fec389 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -1,4 +1,5 @@ import type { Caret, EditorJSModel, CaretManagerEvents } 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'; @@ -38,17 +39,24 @@ export class CaretAdapter extends EventTarget { */ #userCaret: Caret; + /** + * Editor's config + */ + #config: CoreConfig; + /** * @class + * @param config - Editor's config * @param container - Editor.js DOM container * @param model - Editor.js model */ - constructor(container: HTMLElement, model: EditorJSModel) { + constructor(config: CoreConfig, container: HTMLElement, model: EditorJSModel) { super(); + this.#config = config; this.#model = model; this.#container = container; - this.#userCaret = this.#model.createCaret(); + this.#userCaret = this.#model.createCaret(this.#config.userId); const { on } = useSelectionChange(); diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index b2e92985..8d194279 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -12,7 +12,7 @@ import { } from '@editorjs/model'; import type { CaretAdapter } from '../CaretAdapter/index.js'; import { FormattingAction } from '@editorjs/model'; -import type { InlineTool } from '@editorjs/sdk'; +import type { CoreConfig, InlineTool } from '@editorjs/sdk'; import { surround } from '../utils/surround.js'; /** @@ -35,12 +35,19 @@ export class FormattingAdapter { */ #caretAdapter: CaretAdapter; + /** + * Editor's config + */ + #config: CoreConfig; + /** * @class + * @param config - Editor's config * @param model - editor model instance * @param caretAdapter - caret adapter instance */ - constructor(model: EditorJSModel, caretAdapter: CaretAdapter) { + constructor(config: CoreConfig, model: EditorJSModel, caretAdapter: CaretAdapter) { + this.#config = config; this.#model = model; this.#caretAdapter = caretAdapter; @@ -55,7 +62,7 @@ export class FormattingAdapter { * * @param input - input element to apply format to * @param inlineFragment - instance that contains index, toolName and toolData - * @param inlineFragment.index - text range inside of the input element + * @param inlineFragment.index - text range inside the input element * @param inlineFragment.toolName - name of the tool, which format to apply * @param inlineFragment.toolData - additional data for the tool */ @@ -138,11 +145,11 @@ export class FormattingAdapter { switch (action) { case FormattingAction.Format: - this.#model.format(blockIndex, dataKey, toolName, ...range, data); + this.#model.format(this.#config.userId, blockIndex, dataKey, toolName, ...range, data); break; case FormattingAction.Unformat: - this.#model.unformat(blockIndex, dataKey, toolName, ...range); + this.#model.unformat(this.#config.userId, blockIndex, dataKey, toolName, ...range); break; } diff --git a/packages/model/src/CaretManagement/Caret/types.ts b/packages/model/src/CaretManagement/Caret/types.ts index d462fae4..cfa50776 100644 --- a/packages/model/src/CaretManagement/Caret/types.ts +++ b/packages/model/src/CaretManagement/Caret/types.ts @@ -14,6 +14,11 @@ export interface CaretSerialized { * Caret index */ readonly index: string | null; + + /** + * User identifier + */ + userId?: string | number } /** diff --git a/packages/model/src/EditorJSModel.integration.spec.ts b/packages/model/src/EditorJSModel.integration.spec.ts index b8b94e96..69dfb75d 100644 --- a/packages/model/src/EditorJSModel.integration.spec.ts +++ b/packages/model/src/EditorJSModel.integration.spec.ts @@ -19,7 +19,7 @@ describe('[Integration tests] EditorJSModel', () => { model.addEventListener(EventType.Changed, handler); - model.addBlock({ + model.addBlock('user', { name: 'paragraph', data: { text: { diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index c45b4e02..ee911d73 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -5,6 +5,7 @@ import { type BlockNodeSerialized, EditorDocument } from './entities/index.js'; import { EventBus, EventType } from './EventBus/index.js'; import type { ModelEvents, CaretManagerCaretUpdatedEvent, CaretManagerEvents } from './EventBus/index.js'; import { BaseDocumentEvent, type ModifiedEventData } from './EventBus/events/BaseEvent.js'; +import { getContext, WithContext } from './utils/Context.js'; import type { Constructor } from './utils/types.js'; import { CaretManager } from './CaretManagement/index.js'; @@ -79,8 +80,10 @@ export class EditorJSModel extends EventBus { this.#caretManager.addEventListener( EventType.CaretManagerUpdated, (event: CustomEvent) => { + const userId = getContext(); + this.dispatchEvent( - new (event.constructor as Constructor)(event.detail) + new (event.constructor as Constructor)(event.detail, userId) ); } ); @@ -101,20 +104,26 @@ 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 */ - public createCaret(...parameters: Parameters): ReturnType { + @WithContext + public createCaret(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#caretManager.createCaret(...parameters); } /** * Updates caret instance in the model * + * @param _userId - user identifier which is being set to the context * @param parameters - updateCaret method parameters * @param parameters.caret - Caret instance to update */ - public updateCaret(...parameters: Parameters): ReturnType { + @WithContext + public updateCaret(_userId?: string | number, ...parameters: Parameters): ReturnType { + console.trace(); + return this.#caretManager.updateCaret(...parameters); } @@ -122,10 +131,12 @@ export class EditorJSModel extends EventBus { /** * Removes caret instance from the model * + * @param _userId - user identifier which is being set to the context * @param parameters - removeCaret method parameters * @param parameters.caret - Caret instance to remove */ - public removeCaret(...parameters: Parameters): ReturnType { + @WithContext + public removeCaret(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#caretManager.removeCaret(...parameters); } @@ -144,11 +155,13 @@ export class EditorJSModel extends EventBus { * Updates a property of the EditorDocument. * Adds the property if it does not exist. * + * @param _userId - user identifier which is being set to the context * @param parameters - setProperty method parameters * @param parameters.name - The name of the property to update * @param parameters.value - The value to update the property with */ - public setProperty(...parameters: Parameters): ReturnType { + @WithContext + public setProperty(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.setProperty(...parameters); } @@ -156,24 +169,28 @@ export class EditorJSModel extends EventBus { * Adds a BlockNode to the EditorDocument at the specified index. * If no index is provided, the BlockNode will be added to the end of the array. * + * @param _userId - user identifier which is being set to the context * @param parameters - addBlock method parameters * @param parameters.blockNodeData - The data to create the BlockNode with * @param parameters.index - The index at which to add the BlockNode * @throws Error if the index is out of bounds */ - public addBlock(...parameters: Parameters): ReturnType { + @WithContext + public addBlock(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.addBlock(...parameters); } /** * Moves a BlockNode from one index to another * + * @param _userId - user identifier which is being set to the context * @param parameters = moveBlock method parameters * @param parameters.from - The index of the BlockNode to move * @param parameters.to - The index to move the BlockNode to * @throws Error if the index is out of bounds */ - public moveBlock(...parameters: Parameters): ReturnType { + @WithContext + public moveBlock(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.moveBlock(...parameters); } @@ -181,67 +198,79 @@ export class EditorJSModel extends EventBus { /** * Removes a BlockNode from the EditorDocument at the specified index. * + * @param _userId - user identifier which is being set to the context * @param parameters - removeBlock method parameters * @param parameters.index - The index of the BlockNode to remove * @throws Error if the index is out of bounds */ - public removeBlock(...parameters: Parameters): ReturnType { + @WithContext + public removeBlock(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.removeBlock(...parameters); } /** * Inserts data to the specified index * + * @param _userId - user identifier which is being set to the context * @param index - index to insert data * @param data - data to insert (text or blocks) */ - public insertData(index: Index, data: string | BlockNodeSerialized[]): void { + @WithContext + public insertData(_userId: string | number | undefined, index: Index, data: string | BlockNodeSerialized[]): void { this.#document.insertData(index, data); } /** * Removes data from the specified index * + * @param _userId - user identifier which is being set to the context * @param index - index to remove data from * @param data - text or blocks to remove */ - public removeData(index: Index, data: string | BlockNodeSerialized[]): void { + @WithContext + public removeData(_userId: string | number | undefined, index: Index, data: string | BlockNodeSerialized[]): void { this.#document.removeData(index, data); } /** * Modifies data for the specific index * + * @param _userId - user identifier which is being set to the context * @param index - index of data to modify * @param data - data to modify (includes current and previous values) */ - public modifyData(index: Index, data: ModifiedEventData): void { + @WithContext + public modifyData(_userId: string | number | undefined, index: Index, data: ModifiedEventData): void { this.#document.modifyData(index, data); } /** * Updates the ValueNode data associated with the BlockNode at the specified index. * + * @param _userId - user identifier which is being set to the context * @param parameters - updateValue method parameters * @param parameters.blockIndex - The index of the BlockNode to update * @param parameters.dataKey - The key of the ValueNode to update * @param parameters.value - The new value of the ValueNode * @throws Error if the index is out of bounds */ - public updateValue(...parameters: Parameters): ReturnType { + @WithContext + public updateValue(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.updateValue(...parameters); } /** * Updates BlockTune data associated with the BlockNode at the specified index. * + * @param _userId - user identifier which is being set to the context * @param parameters - updateTuneData method parameters * @param parameters.blockIndex - The index of the BlockNode to update * @param parameters.tuneName - The name of the BlockTune to update * @param parameters.data - The data to update the BlockTune with * @throws Error if the index is out of bounds */ - public updateTuneData(...parameters: Parameters): ReturnType { + @WithContext + public updateTuneData(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.updateTuneData(...parameters); } @@ -259,32 +288,37 @@ export class EditorJSModel extends EventBus { /** * Inserts text to the specified block * + * @param _userId - user identifier which is being set to the context * @param parameters - insertText method parameters * @param parameters.blockIndex - index of the block * @param parameters.dataKey - key of the data * @param parameters.text - text to insert * @param [parameters.start] - char index where to insert text */ - public insertText(...parameters: Parameters): ReturnType { + @WithContext + public insertText(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.insertText(...parameters); } /** * Removes text from specified block * + * @param _userId - user identifier which is being set to the context * @param parameters - removeText method parameters * @param parameters.blockIndex - index of the block * @param parameters.dataKey - key of the data * @param [parameters.start] - start char index of the range * @param [parameters.end] - end char index of the range */ - public removeText(...parameters: Parameters): ReturnType { + @WithContext + public removeText(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.removeText(...parameters); } /** * Formats text in the specified block * + * @param _userId - user identifier which is being set to the context * @param parameters - format method parameters * @param parameters.blockIndex - index of the block * @param parameters.dataKey - key of the data @@ -293,13 +327,15 @@ export class EditorJSModel extends EventBus { * @param parameters.end - end char index of the range * @param [parameters.data] - Inline Tool data if applicable */ - public format(...parameters: Parameters): ReturnType { + @WithContext + public format(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.format(...parameters); } /** * Removes formatting from the specified block * + * @param _userId - user identifier which is being set to the context * @param parameters - unformat method parameters * @param parameters.blockIndex - index of the block * @param parameters.key - key of the data @@ -307,7 +343,8 @@ export class EditorJSModel extends EventBus { * @param parameters.start - start char index of the range * @param parameters.end - end char index of the range */ - public unformat(...parameters: Parameters): ReturnType { + @WithContext + public unformat(_userId?: string | number, ...parameters: Parameters): ReturnType { return this.#document.unformat(...parameters); } @@ -349,12 +386,18 @@ export class EditorJSModel extends EventBus { return; } + const userId = getContext(); + /** * Here could be any logic to filter EditorDocument events; */ this.dispatchEvent( - new (event.constructor as Constructor)(event.detail.index, event.detail.data) + new (event.constructor as Constructor)( + event.detail.index, + event.detail.data, + userId + ) ); } ); diff --git a/packages/model/src/EventBus/events/BaseEvent.ts b/packages/model/src/EventBus/events/BaseEvent.ts index 989faf87..52fbeaf8 100644 --- a/packages/model/src/EventBus/events/BaseEvent.ts +++ b/packages/model/src/EventBus/events/BaseEvent.ts @@ -20,6 +20,11 @@ export interface EventPayloadBase { * The data of the changed information */ data: Data; + + /** + * User identifier + */ + userId?: number | string; } /** @@ -40,13 +45,15 @@ export class BaseDocumentEvent exten * @param index - index of the modified value in the document * @param action - event action * @param data - event data + * @param userId - user identifier */ - constructor(index: Index, action: Action, data: Data) { + constructor(index: Index, action: Action, data: Data, userId?: string | number) { super(EventType.Changed, { detail: { index, action, data, + userId, }, }); } diff --git a/packages/model/src/EventBus/events/BlockAddedEvent.ts b/packages/model/src/EventBus/events/BlockAddedEvent.ts index 446bd41b..716e22a2 100644 --- a/packages/model/src/EventBus/events/BlockAddedEvent.ts +++ b/packages/model/src/EventBus/events/BlockAddedEvent.ts @@ -13,9 +13,10 @@ export class BlockAddedEvent extends BaseDocumentEvent { * CaretManagerCaretAddedEvent class constructor * * @param payload - event payload + * @param userId - user identifier */ - constructor(payload: CaretSerialized) { + constructor(payload: CaretSerialized, userId?: string | number) { // Stryker disable next-line ObjectLiteral super(EventType.CaretManagerUpdated, { - detail: payload, + detail: { + ...payload, + userId, + }, }); } } diff --git a/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts b/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts index 9ee6ef17..b0596a0b 100644 --- a/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts +++ b/packages/model/src/EventBus/events/CaretManagerCaretRemovedEvent.ts @@ -9,11 +9,15 @@ export class CaretManagerCaretRemovedEvent extends CustomEvent * CaretManagerCaretRemovedEvent class constructor * * @param payload - event payload + * @param userId - user identifier */ - constructor(payload: CaretSerialized) { + constructor(payload: CaretSerialized, userId?: string | number) { // Stryker disable next-line ObjectLiteral super(EventType.CaretManagerUpdated, { - detail: payload, + detail: { + ...payload, + userId, + }, }); } } diff --git a/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts b/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts index de3996ed..e2ebb942 100644 --- a/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts +++ b/packages/model/src/EventBus/events/CaretManagerCaretUpdatedEvent.ts @@ -9,11 +9,15 @@ export class CaretManagerCaretUpdatedEvent extends CustomEvent * CaretManagerCaretUpdatedEvent class constructor * * @param payload - event payload + * @param userId - user identifier */ - constructor(payload: CaretSerialized) { + constructor(payload: CaretSerialized, userId?: string | number) { // Stryker disable next-line ObjectLiteral super(EventType.CaretManagerUpdated, { - detail: payload, + detail: { + ...payload, + userId, + }, }); } } diff --git a/packages/model/src/EventBus/events/PropertyModifiedEvent.ts b/packages/model/src/EventBus/events/PropertyModifiedEvent.ts index bcd7a821..a520839c 100644 --- a/packages/model/src/EventBus/events/PropertyModifiedEvent.ts +++ b/packages/model/src/EventBus/events/PropertyModifiedEvent.ts @@ -12,8 +12,9 @@ export class PropertyModifiedEvent

extends BaseDocumentEvent) { - super(index, EventAction.Modified, data); + 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 295e3666..f3f4e1c7 100644 --- a/packages/model/src/EventBus/events/TextAddedEvent.ts +++ b/packages/model/src/EventBus/events/TextAddedEvent.ts @@ -12,8 +12,9 @@ export class TextAddedEvent extends BaseDocumentEvent * * @param index - index of the added text in the document * @param text - added text + * @param userId - user identifier */ - constructor(index: Index, text: string) { - super(index, EventAction.Added, text); + 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 775bb406..4dd49180 100644 --- a/packages/model/src/EventBus/events/TextFormattedEvent.ts +++ b/packages/model/src/EventBus/events/TextFormattedEvent.ts @@ -17,8 +17,9 @@ export class TextFormattedEvent extends BaseDocumentEvent extends BaseDocumentEvent) { - super(index, EventAction.Modified, data); + 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 9601fe82..8b3e91e5 100644 --- a/packages/model/src/EventBus/events/ValueModifiedEvent.ts +++ b/packages/model/src/EventBus/events/ValueModifiedEvent.ts @@ -12,8 +12,9 @@ export class ValueModifiedEvent extends BaseDocumentEvent) { - super(index, EventAction.Modified, data); + constructor(index: Index, data: ModifiedEventData, userId?: string | number) { + super(index, EventAction.Modified, data, userId); } } diff --git a/packages/model/src/utils/Context.spec.ts b/packages/model/src/utils/Context.spec.ts new file mode 100644 index 00000000..454624da --- /dev/null +++ b/packages/model/src/utils/Context.spec.ts @@ -0,0 +1,44 @@ +import { getContext, runWithContext, WithContext } from './Context.js'; + +const func = (): string | undefined => { + return getContext(); +}; + +describe('Context util', () => { + it('should run function in context', () => { + expect(runWithContext('context', func)).toEqual('context'); + }); + + it('should run several functions in context respectively', () => { + const result1 = runWithContext('context1', func); + const result2 = runWithContext('context2', func); + + expect(result1).toEqual('context1'); + expect(result2).toEqual('context2'); + }); + + it('should run method in context', () => { + // eslint-disable-next-line jsdoc/require-jsdoc + class Test { + @WithContext + // eslint-disable-next-line @typescript-eslint/no-unused-vars,no-unused-vars,jsdoc/require-jsdoc + public method(_context: string): string | undefined { + return getContext(); + } + } + + const instance = new Test(); + + expect(instance.method('context')).toEqual('context'); + }); + + it('should return undefined as a context outside of run', () => { + expect(getContext()).toBeUndefined(); + }); + + it('should return undefined as a context after a function call in the context', () => { + runWithContext('context', func); + + expect(getContext()).toBeUndefined(); + }); +}); diff --git a/packages/model/src/utils/Context.ts b/packages/model/src/utils/Context.ts new file mode 100644 index 00000000..29923721 --- /dev/null +++ b/packages/model/src/utils/Context.ts @@ -0,0 +1,47 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const ContextStack: any[] = []; + +/** + * Puts context value to the context stack so function is able to retrieve it + * + * @param context - context value + * @param fn - function to call + */ +export function runWithContext(context: C, fn: () => T): T { + ContextStack.push(context); + + try { + return fn(); + } finally { + ContextStack.pop(); + } +} + +/** + * Returns current context value + */ +export function getContext(): C | undefined { + return ContextStack[ContextStack.length - 1]; +} + + +/** + * Decorator to run a class method inside a context + * + * @param _target - target class + * @param _propertyKey - method name + * @param descriptor - method descriptor + */ +export function WithContext(_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor { + const originalMethod = descriptor.value; + + descriptor.value = function (...args: unknown[]) { + // The first argument should be the context (context payload) + const context = args[0]; // context will be the first argument passed to the method + + // Execute the method within the provided context + return runWithContext(context, () => originalMethod.apply(this, args)); + }; + + return descriptor; +} diff --git a/packages/playground/tsconfig.json b/packages/playground/tsconfig.json index ec8071bb..31f9f624 100644 --- a/packages/playground/tsconfig.json +++ b/packages/playground/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../core/tsconfig.json" + }, + { + "path": "../collaboration-manager/tsconfig.json", } ] } diff --git a/packages/sdk/src/entities/Config.ts b/packages/sdk/src/entities/Config.ts index ff88890c..58413242 100644 --- a/packages/sdk/src/entities/Config.ts +++ b/packages/sdk/src/entities/Config.ts @@ -17,4 +17,9 @@ export interface CoreConfig extends EditorConfig { * @param model - EditorJSModel instance */ onModelUpdate?: (model: EditorJSModel) => void; + + /** + * Current user's identifier + */ + userId?: string | number; } diff --git a/packages/sdk/src/entities/InlineTool.ts b/packages/sdk/src/entities/InlineTool.ts index 3cd6fede..5dfb1add 100644 --- a/packages/sdk/src/entities/InlineTool.ts +++ b/packages/sdk/src/entities/InlineTool.ts @@ -1,4 +1,4 @@ -import type { TextRange, InlineFragment, FormattingAction, IntersectType, InlineToolName } from '@editorjs/model'; +import type { TextRange, InlineFragment, FormattingAction, IntersectType } from '@editorjs/model'; import type { InlineTool as InlineToolVersion2 } from '@editorjs/editorjs'; import type { InlineToolConstructable as InlineToolConstructableV2 } from '@editorjs/editorjs'; import type { InlineToolConstructorOptions as InlineToolConstructorOptionsVersion2 } from '@editorjs/editorjs'; diff --git a/yarn.lock b/yarn.lock index b7ff2cd4..dd95ec5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -645,6 +645,7 @@ __metadata: resolution: "@editorjs/collaboration-manager@workspace:packages/collaboration-manager" dependencies: "@editorjs/model": "workspace:^" + "@editorjs/sdk": "workspace:^" "@jest/globals": "npm:^29.7.0" "@stryker-mutator/core": "npm:^7.0.2" "@stryker-mutator/jest-runner": "npm:^7.0.2"