From de7d7c3c5b8b29a78498fb29ebbd1aa6e6a0a239 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 14 May 2026 00:20:04 +0100 Subject: [PATCH 01/10] Add API methods to update the model contents --- .../src/api/BlocksAPI.integration.spec.ts | 6 +- packages/core/src/api/BlocksAPI.spec.ts | 85 +++++++-- packages/core/src/api/BlocksAPI.ts | 85 ++++++++- .../src/api/DocumentAPI/DocumentAPI.spec.ts | 3 + .../core/src/api/DocumentAPI/DocumentAPI.ts | 14 +- packages/core/src/api/SelectionAPI.spec.ts | 15 +- packages/core/src/api/SelectionAPI.ts | 49 ++++- packages/core/src/api/TextAPI.ts | 176 ++++++++++++++++++ packages/core/src/api/index.ts | 7 + packages/core/src/index.ts | 1 - .../src/BlockToolAdapter/index.ts | 92 +++++---- .../dom-adapters/src/CaretAdapter/index.ts | 26 ++- .../src/FormattingAdapter/index.ts | 20 +- .../dom-adapters/src/InputsRegistry/index.ts | 12 +- packages/dom-adapters/src/index.ts | 12 +- packages/dom-adapters/src/tokens.ts | 5 + packages/model/src/EditorJSModel.ts | 6 +- .../model/src/EventBus/types/EventType.ts | 4 +- .../model/src/entities/BlockNode/index.ts | 2 +- .../src/entities/EditorDocument/index.ts | 4 +- packages/sdk/src/api/BlocksAPI.ts | 54 +++++- packages/sdk/src/api/DocumentAPI.ts | 8 +- packages/sdk/src/api/EditorAPI.ts | 6 + packages/sdk/src/api/SelectionAPI.ts | 20 +- packages/sdk/src/api/TextAPI.ts | 96 ++++++++++ packages/sdk/src/api/index.ts | 1 + packages/sdk/src/entities/BlockToolAdapter.ts | 66 +++---- .../sdk/src/entities/EditorjsAdapterPlugin.ts | 17 +- packages/sdk/src/entities/EditorjsPlugin.ts | 6 +- 29 files changed, 712 insertions(+), 186 deletions(-) create mode 100644 packages/core/src/api/TextAPI.ts create mode 100644 packages/sdk/src/api/TextAPI.ts diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 82619fb6..fd23bbab 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -82,7 +82,11 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { config ); - blocksAPI = new BlocksAPI(blocksManager, config); + blocksAPI = new BlocksAPI( + blocksManager, + config, + new EditorJSModel('userId', { identifier: 'documentId' }) + ); }); afterEach(() => { diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index cc502c1d..a0acb4b2 100644 --- a/packages/core/src/api/BlocksAPI.spec.ts +++ b/packages/core/src/api/BlocksAPI.spec.ts @@ -14,7 +14,14 @@ jest.unstable_mockModule('../components/BlockManager', () => ({ })), })); +jest.unstable_mockModule('@editorjs/model', () => ({ + EditorJSModel: jest.fn(), + createBlockId: jest.fn(id => id), + createDataKey: jest.fn(key => key), +})); + const { BlocksManager } = await import('../components/BlockManager'); +const { EditorJSModel } = await import('@editorjs/model'); const { BlocksAPI } = await import('./BlocksAPI.js'); import type { CoreConfigValidated } from '@editorjs/sdk'; @@ -26,7 +33,11 @@ describe('BlocksAPI', () => { describe('.clear()', () => { it('should call blocksManager.clear', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); api.clear(); @@ -36,10 +47,16 @@ describe('BlocksAPI', () => { describe('.render()', () => { it('should call blocksManager.render with provided document', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); - const doc = { identifier: 'doc', + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); + const doc = { + identifier: 'doc', blocks: [], - properties: {} }; + properties: {}, + }; api.render(doc); @@ -49,7 +66,11 @@ describe('BlocksAPI', () => { describe('.delete()', () => { it('should pass explicit index to blocksManager.deleteBlock', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); api.delete(2); @@ -57,7 +78,11 @@ describe('BlocksAPI', () => { }); it('should pass undefined when index is omitted', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); api.delete(); @@ -67,7 +92,11 @@ describe('BlocksAPI', () => { describe('.move()', () => { it('should call blocksManager.move with toIndex and fromIndex', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); api.move(3, 1); @@ -77,7 +106,11 @@ describe('BlocksAPI', () => { describe('.getBlocksCount()', () => { it('should return blocksManager.blocksCount', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); // @ts-expect-error - need to assign a value to check the method blocksManager.blocksCount = 5; @@ -88,10 +121,16 @@ describe('BlocksAPI', () => { describe('.insertMany()', () => { it('should pass blocks and index to blocksManager.insertMany', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); - const blocks = [{ name: 'a', - data: {} }]; + const blocks = [{ + name: 'a', + data: {}, + }]; api.insertMany(blocks as never, 4); @@ -99,10 +138,16 @@ describe('BlocksAPI', () => { }); it('should pass undefined index to blocksManager.insertMany when omitted', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); - const blocks = [{ name: 'a', - data: {} }]; + const blocks = [{ + name: 'a', + data: {}, + }]; api.insertMany(blocks as never); @@ -112,7 +157,11 @@ describe('BlocksAPI', () => { describe('.insert()', () => { it('should use defaults and pass payload to blocksManager.insert', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); api.insert(); @@ -125,7 +174,11 @@ describe('BlocksAPI', () => { }); it('should pass provided params to blocksManager.insert and ignore compatibility args', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) + ); api.insert( 'header', diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index a29055e1..328c0c3d 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -5,7 +5,14 @@ import { BlocksManager } from '../components/BlockManager.js'; import { BlockToolData } from '@editorjs/editorjs'; import { CoreConfigValidated } from '@editorjs/sdk'; import { BlocksAPI as BlocksApiInterface } from '@editorjs/sdk'; -import { type BlockNodeInit, type EditorDocumentSerialized } from '@editorjs/model'; +import { + BlockId, BlockIndexOrId, + type BlockNodeInit, + createBlockId, createDataKey, + type EditorDocumentSerialized, + EditorJSModel, + TextNodeSerialized, ValueSerialized +} from '@editorjs/model'; /** * Blocks API @@ -23,17 +30,25 @@ export class BlocksAPI implements BlocksApiInterface { */ #config: CoreConfigValidated; + /** + * Model instance + */ + #model: EditorJSModel; + /** * BlocksAPI class constructor * @param blocksManager - BlocksManager instance to work with blocks * @param config - EditorJS configuration + * @param model - EditorJS model instance */ constructor( blocksManager: BlocksManager, - @inject(TOKENS.EditorConfig) config: CoreConfigValidated + @inject(TOKENS.EditorConfig) config: CoreConfigValidated, + model: EditorJSModel ) { this.#blocksManager = blocksManager; this.#config = config; + this.#model = model; } /** @@ -113,4 +128,70 @@ export class BlocksAPI implements BlocksApiInterface { focus, }); }; + + /** + * Returns block's index by its id + * @param id - block id to get index for + */ + public getIndexById(id: string): number { + return this.#model.getBlockIndexById(createBlockId(id)); + } + + /** + * Returns block id by its index + * @param index - block index to get id for + */ + public getIdByIndex(index: number): BlockId | undefined { + return this.#model.getBlockId(index); + } + + /** + * Returns serialized data for provided data key + * @param blockIndexOrId - index or identifier of the block + * @param dataKey - data key to get serialized data for + */ + public getData(blockIndexOrId: number | string, dataKey: string): TextNodeSerialized | ValueSerialized | undefined { + /** + * Need an explicit cast here because TS doesn't pass generic for some reason + */ + return this.#model.getDataNode(blockIndexOrId as BlockIndexOrId, dataKey) as TextNodeSerialized | ValueSerialized | undefined; + } + + /** + * Creates data node with the given key + * @param indexOrId - index or id of the block + * @param dataKey - data key of the new data node + * @param [initialData] - optional initial data + */ + public createData( + indexOrId: number | string, + dataKey: string, + initialData?: TextNodeSerialized | ValueSerialized + ): void { + this.#model.createDataNode( + this.#config.userId, + indexOrId as BlockIndexOrId, + dataKey, + initialData + ); + } + + /** + * Removes data by the data key + * @param blockIndexOrId - index or identifier of the block + * @param dataKey - data key of the node to remove + */ + public removeData(blockIndexOrId: string | number, dataKey: string): void { + this.#model.removeDataNode(this.#config.userId, blockIndexOrId as BlockIndexOrId, dataKey); + } + + /** + * Updates value by the given key + * @param blockIndexOrId - index or identifier of the block + * @param dataKey - key of the data node to update + * @param value - new value + */ + public updateValue(blockIndexOrId: string | number, dataKey: string, value: V): void { + this.#model.updateValue(this.#config.userId, blockIndexOrId as BlockIndexOrId, createDataKey(dataKey), value); + } } diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts index fa3b340a..943dea0f 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.spec.ts @@ -8,6 +8,9 @@ jest.unstable_mockModule('@editorjs/model', () => { return { EditorJSModel, + EventType: { + Changed: 'update', + }, }; }); diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.ts index 4cefd28a..a3151501 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; -import { type EditorDocumentSerialized, EditorJSModel } from '@editorjs/model'; +import { type EditorDocumentSerialized, EditorJSModel, EventType, ModelEvents } from '@editorjs/model'; import { DocumentAPI as DocumentApiInterface } from '@editorjs/sdk'; import { injectable } from 'inversify'; @@ -30,4 +30,16 @@ export class DocumentAPI implements DocumentApiInterface { public get data(): EditorDocumentSerialized { return this.#model.serialized; } + + /** + * Registers model's update callback. Returns a cleanup function + * @param callback - callback called on model update + */ + public onUpdate(callback: (event: ModelEvents) => void): () => void { + this.#model.addEventListener(EventType.Changed, callback); + + return () => { + this.#model.removeEventListener(EventType.Changed, callback); + }; + } } diff --git a/packages/core/src/api/SelectionAPI.spec.ts b/packages/core/src/api/SelectionAPI.spec.ts index 04d4be4d..88ee3fe4 100644 --- a/packages/core/src/api/SelectionAPI.spec.ts +++ b/packages/core/src/api/SelectionAPI.spec.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { jest } from '@jest/globals'; +import type { CoreConfigValidated } from '@editorjs/sdk'; // Mock dependencies before importing the module under test jest.unstable_mockModule('../components/SelectionManager', () => ({ @@ -9,12 +10,16 @@ jest.unstable_mockModule('../components/SelectionManager', () => ({ })); jest.unstable_mockModule('@editorjs/model', () => ({ + EditorJSModel: jest.fn(), createInlineToolName: jest.fn((name: string) => `inline:${name}`), + EventType: { + CaretManagerUpdated: 'update', + }, })); const { SelectionAPI } = await import('./SelectionAPI.js'); const { SelectionManager } = await import('../components/SelectionManager'); -const { createInlineToolName } = await import('@editorjs/model'); +const { EditorJSModel, createInlineToolName } = await import('@editorjs/model'); describe('SelectionAPI', () => { // @ts-expect-error - mock object @@ -22,9 +27,13 @@ describe('SelectionAPI', () => { describe('.applyInlineToolForCurrentSelection()', () => { it('should convert toolName and delegate to SelectionManager', () => { - const api = new SelectionAPI(selectionManager as never as InstanceType); + const api = new SelectionAPI( + selectionManager as unknown as InstanceType, + new EditorJSModel('userId', { identifier: 'docId' }), + {} as unknown as CoreConfigValidated + ); - api.applyInlineToolForCurrentSelection('bold', { level: 1 } as never); + api.applyInlineToolForCurrentSelection('bold', { level: 1 }); expect(createInlineToolName).toHaveBeenCalledWith('bold'); expect(selectionManager.applyInlineToolForCurrentSelection).toHaveBeenCalledWith('inline:bold', { level: 1 }); diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index 4e139b26..06b88022 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -1,10 +1,11 @@ import 'reflect-metadata'; -import { injectable } from 'inversify'; +import { inject, injectable } from 'inversify'; import { SelectionManager } from '../components/SelectionManager.js'; -import { createInlineToolName } from '@editorjs/model'; -import { InlineToolFormatData } from '@editorjs/sdk'; +import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType } from '@editorjs/model'; +import { CoreConfigValidated, InlineToolFormatData } from '@editorjs/sdk'; import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; +import { TOKENS } from '../tokens'; /** * Selection API class @@ -13,16 +14,24 @@ import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; @injectable() export class SelectionAPI implements SelectionApiInterface { #selectionManager: SelectionManager; + #model: EditorJSModel; + #config: CoreConfigValidated; /** * SelectionAPI class constructor - * @param selectionManager - SelectionManager instance to work with selection and inline fotmatting + * @param selectionManager - SelectionManager instance to work with selection and inline formatting + * @param model - EditorJS model instance + * @param config - EditorJS validated config */ constructor( - selectionManager: SelectionManager + selectionManager: SelectionManager, + model: EditorJSModel, + @inject(TOKENS.EditorConfig) config: CoreConfigValidated ) { this.#selectionManager = selectionManager; - }; + this.#model = model; + this.#config = config; + } /** * Applies passed inline tool to the current selection @@ -32,4 +41,32 @@ export class SelectionAPI implements SelectionApiInterface { public applyInlineToolForCurrentSelection(toolName: string, data?: InlineToolFormatData): void { this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(toolName), data); } + + /** + * Registers a callback for CaretManager updates. Returns a cleanup function + * @param callback - callback for CaretManager updates + */ + public onCaretUpdate(callback: (event: CaretManagerEvents) => void): () => void { + this.#model.addEventListener(EventType.CaretManagerUpdated, callback); + + return () => { + this.#model.removeEventListener(EventType.CaretManagerUpdated, callback); + }; + } + + /** + * Creates a new caret for a user + * @param userId - user id. If not provided, creates for current user + */ + public createCaret(userId = this.#config.userId): Caret { + return this.#model.createCaret(userId); + } + + /** + * Returns user caret + * @param userId - user id. If not provided, returns for current user + */ + public getCaret(userId = this.#config.userId): Caret | undefined { + return this.#model.getCaret(userId); + } } diff --git a/packages/core/src/api/TextAPI.ts b/packages/core/src/api/TextAPI.ts new file mode 100644 index 00000000..ae3bd15a --- /dev/null +++ b/packages/core/src/api/TextAPI.ts @@ -0,0 +1,176 @@ +import { + BlockIndexOrId, + createDataKey, + createInlineToolData, + createInlineToolName, + EditorJSModel, InlineFragment +} from '@editorjs/model'; +import type { CoreConfigValidated } from '@editorjs/sdk'; +import { inject, injectable } from 'inversify'; +import { TOKENS } from '../tokens'; +import { TextAPI as TextAPIInterface } from '@editorjs/sdk'; + +/** + * Text API to work with the text content of the document + */ +@injectable() +export class TextAPI implements TextAPIInterface { + /** + * EditorJS Model instance + */ + #model: EditorJSModel; + + /** + * Validated Editor's Config + */ + #config: CoreConfigValidated; + + /** + * Class constructor function + * @param config - Editor's validated config + * @param model - EditorJS model instance + */ + constructor( + @inject(TOKENS.EditorConfig) config: CoreConfigValidated, + model: EditorJSModel + ) { + this.#model = model; + this.#config = config; + } + + /** + * Inserts text at a given position + * @param text - new text to insert + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - start offset + */ + public insert( + text: string, + blockIndexOrId: number | string, + dataKey: string, + start?: number + ): void { + this.#model.insertText( + this.#config.userId, + blockIndexOrId as BlockIndexOrId, + createDataKey(dataKey), + text, + start + ); + } + + /** + * Removes text from a given range. Returns removed text + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + */ + public remove( + blockIndexOrId: number | string, + dataKey: string, + start?: number, + end?: number + ): string { + return this.#model.removeText( + this.#config.userId, + blockIndexOrId as BlockIndexOrId, + createDataKey(dataKey), + start, + end + ); + } + + /** + * Formats the given range + * @param tool - tool to apply + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + * @param data - optional tool's data + */ + public format( + tool: string, + blockIndexOrId: number | string, + dataKey: string, + start: number, + end: number, + data?: Record + ): void { + this.#model.format( + this.#config.userId, + blockIndexOrId as BlockIndexOrId, + createDataKey(dataKey), + createInlineToolName(tool), + start, + end, + data !== undefined ? createInlineToolData(data) : undefined + ); + } + + /** + * Unformats the given range + * @param tool - tool to remove + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + */ + public unformat( + tool: string, + blockIndexOrId: number | string, + dataKey: string, + start: number, + end: number + ): void { + this.#model.unformat( + this.#config.userId, + blockIndexOrId as BlockIndexOrId, + createDataKey(dataKey), + createInlineToolName(tool), + start, + end + ); + } + + /** + * Returns applied inline fragments for a given range + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + * @param tool - optional filter tool. If provided, will return only fragments of the given tool + */ + public getFragments( + blockIndexOrId: number | string, + dataKey: string, + start?: number, + end?: number, + tool?: string + ): InlineFragment[] { + return this.#model.getFragments( + blockIndexOrId as BlockIndexOrId, + createDataKey(dataKey), + start, + end, + tool !== undefined ? createInlineToolName(tool) : undefined + ); + } + + /** + * Returns text content of the text node + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + */ + public get( + blockIndexOrId: number | string, + dataKey: string + ): string { + return this.#model.getText( + blockIndexOrId as BlockIndexOrId, + createDataKey(dataKey) + ); + } +} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index b1b5c28a..3909cc60 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -4,6 +4,7 @@ import { EditorAPI as EditorApiInterface } from '@editorjs/sdk'; import { BlocksAPI } from './BlocksAPI.js'; import { SelectionAPI } from './SelectionAPI.js'; import { DocumentAPI } from './DocumentAPI/index.js'; +import { TextAPI } from './TextAPI.js'; /** * Class gathers all Editor's APIs @@ -27,4 +28,10 @@ export class EditorAPI implements EditorApiInterface { */ @inject(DocumentAPI) public document!: DocumentAPI; + + /** + * Text API instance to work with the text content of the document + */ + @inject(TextAPI) + public text!: TextAPI; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 002d8555..a28bb6c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -240,7 +240,6 @@ export default class Core { const api = ctx.get(EditorAPI); return new Adapter({ - model: this.#model, config: this.#config, api, eventBus, diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 070b0814..9958bbaa 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -1,7 +1,5 @@ import { createDataKey, - type DataKey, - EditorJSModel, EventAction, IndexBuilder, type ModelEvents, @@ -11,7 +9,7 @@ import { import type { BeforeInputUIEvent, BeforeInputUIEventPayload, - CoreConfig + CoreConfig, EditorAPI } from '@editorjs/sdk'; import { BeforeInputUIEventName, BlockToolAdapter, EventBus } from '@editorjs/sdk'; @@ -52,28 +50,31 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #beforeInputListener: EventListener; + #api: EditorAPI; + /** * BlockToolAdapter constructor * @param config - Editor's config - * @param model - EditorJSModel instance * @param eventBus - Editor EventBus instance * @param caretAdapter - CaretAdapter instance * @param formattingAdapter - needed to render formatted text * @param registry - shared inputs registry + * @param api - EditorJS API */ constructor( @inject(TOKENS.EditorConfig) config: Required, - model: EditorJSModel, eventBus: EventBus, caretAdapter: CaretAdapter, formattingAdapter: FormattingAdapter, - registry: InputsRegistry + registry: InputsRegistry, + @inject(TOKENS.EditorAPI) api: EditorAPI ) { - super(config, model, eventBus); + super(config, api, eventBus); this.#caretAdapter = caretAdapter; this.#formattingAdapter = formattingAdapter; this.#inputsRegistry = registry; + this.#api = api; /** * @param event - BeforeInputEvent @@ -109,12 +110,10 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { /** * Attaches or re-attaches input to the model using key. * Each input registered in the InputRegistry — the shared registry of all inputs in the editor - * @param keyRaw - tools data key to attach input to + * @param key - tools data key to attach input to * @param input - input element */ - public setInput(keyRaw: string, input: HTMLElement | undefined): void { - const key = createDataKey(keyRaw); - + public setInput(key: string, input: HTMLElement | undefined): void { if (input === undefined) { this.#inputsRegistry.unregister(this.blockId, key); @@ -135,8 +134,8 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { return; } - const value = this.model.getText(this.blockId, key); - const fragments = this.model.getFragments(this.blockId, key); + const value = this.#api.text.get(this.blockId, key); + const fragments = this.#api.text.getFragments(this.blockId, key); this.#inputsRegistry.register(this.blockId, key, input); @@ -150,8 +149,8 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { /** * Returns the (dataKey → element) map for this block from the shared registry. */ - get #attachedInputs(): Map { - return this.#inputsRegistry.getBlockInputs(this.blockId) ?? new Map(); + get #attachedInputs(): Map { + return this.#inputsRegistry.getBlockInputs(this.blockId) ?? new Map(); } /** @@ -159,7 +158,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * Public getter for all attached inputs. * Can be used to loop through all inputs to find a particular input(s) */ - public getAttachedInputs(): Map { + public getAttachedInputs(): Map { return this.#attachedInputs; } @@ -168,7 +167,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * Allows access to a particular input by key * @param key - data key of the input */ - public getInput(key: DataKey): HTMLElement | undefined { + public getInput(key: string): HTMLElement | undefined { return this.#attachedInputs.get(key); } @@ -177,7 +176,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param targetRanges - ranges to find inputs for * @returns array of tuples containing data key and input element */ - #findInputsByRanges(targetRanges: StaticRange[]): [DataKey, HTMLElement][] { + #findInputsByRanges(targetRanges: StaticRange[]): [string, HTMLElement][] { return Array.from(this.#attachedInputs.entries()).filter(([_, input]) => { return targetRanges.some((range) => { const startContainer = range.startContainer; @@ -274,7 +273,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #handleDeleteInContentEditable( input: HTMLElement, - key: DataKey, + key: string, range: StaticRange, isRestoreCaretToTheEnd: boolean = false ): void { @@ -282,7 +281,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * Middle block in a cross-input selection: remove the whole block, not the same as removeText(0, length). */ if (isInputInBetweenSelection(input, range)) { - this.model.removeBlock(this.config.userId, this.blockId); + this.#api.blocks.delete(this.blockId); return; } @@ -310,7 +309,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { } const [start, end] = clipped; - const removedText = this.model.removeText(this.config.userId, this.blockId, key, start, end); + const removedText = this.#api.text.remove(this.blockId, key, start, end); let newCaretIndex: number | null = null; @@ -335,8 +334,8 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (newCaretIndex !== null) { this.#caretAdapter.updateIndex( new IndexBuilder() - .addBlockIndex(this.model.getBlockIndexById(this.blockId)) - .addDataKey(key) + .addBlockIndex(this.#api.blocks.getIndexById(this.blockId)) + .addDataKey(createDataKey(key)) .addTextRange([newCaretIndex, newCaretIndex]) .build() ); @@ -351,7 +350,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param input - input element * @param key - data key input is attached to */ - #handleBeforeInputEvent(payload: BeforeInputUIEventPayload, input: HTMLElement, key: DataKey): void { + #handleBeforeInputEvent(payload: BeforeInputUIEventPayload, input: HTMLElement, key: string): void { const { data, inputType, targetRanges } = payload; const range = targetRanges[0]; const isFormattingInputType = inputType.startsWith('format'); @@ -376,7 +375,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (data !== undefined && input.contains(range.startContainer)) { start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.model.insertText(this.config.userId, this.blockId, key, data, start); + this.#api.text.insert(data, this.blockId, key, start); } break; } @@ -388,7 +387,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (data !== undefined && input.contains(range.startContainer)) { start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.model.insertText(this.config.userId, this.blockId, key, data, start); + this.#api.text.insert(data, this.blockId, key, start); } break; } @@ -442,12 +441,12 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param start - start index of the split * @param end - end index of the selected range */ - #handleSplit(key: DataKey, start: number, end: number): void { - const currentBlockIndex = this.model.getBlockIndexById(this.blockId); - const currentValue = this.model.getText(this.blockId, key); + #handleSplit(key: string, start: number, end: number): void { + const currentBlockIndex = this.#api.blocks.getIndexById(this.blockId); + const currentValue = this.#api.text.get(this.blockId, key); const newValueAfter = currentValue.slice(end); - const relatedFragments = this.model.getFragments(this.blockId, key, end, currentValue.length); + const relatedFragments = this.#api.text.getFragments(this.blockId, key, end, currentValue.length); /** * Fragment ranges bounds should be decreased by end index, because end is the index of the first character of the new block @@ -457,20 +456,17 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { fragment.range[1] -= end; }); - this.model.removeText(this.config.userId, this.blockId, key, start, currentValue.length); - this.model.addBlock( - this.config.userId, + this.#api.text.remove(this.blockId, key, start, currentValue.length); + this.#api.blocks.insert( + /** + * @todo when implementing split/merge, think of how to not use toolname here + */ + this.#toolName, { - /** - * @todo when implementing split/merge, think of how to not use toolname here - */ - name: this.#toolName, - data: { - [key]: { - $t: 't', - value: newValueAfter, - fragments: relatedFragments, - }, + [key]: { + $t: 't', + value: newValueAfter, + fragments: relatedFragments, }, }, currentBlockIndex + 1 @@ -483,7 +479,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { this.#caretAdapter.updateIndex( new IndexBuilder() .addBlockIndex(currentBlockIndex + 1) - .addDataKey(key) + .addDataKey(createDataKey(key)) .addTextRange([0, 0]) .build() ); @@ -496,7 +492,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * @param input - input element * @param key - data key input is attached to */ - #handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: DataKey): void { + #handleModelUpdateForContentEditableElement(event: ModelEvents, input: HTMLElement, key: string): void { const { userId, index, action } = event.detail; const { textRange, blockIndex: eventBlockIndex } = index; @@ -510,7 +506,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { const builder = new IndexBuilder(); - builder.addDataKey(key).addBlockIndex(eventBlockIndex); + builder.addDataKey(createDataKey(key)).addBlockIndex(eventBlockIndex); let newCaretIndex: number | null = null; @@ -552,12 +548,12 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { const { textRange, dataKey } = event.detail.index; - const input = this.#attachedInputs.get(dataKey!); + const input = this.#attachedInputs.get(dataKey as string); if (!input || textRange === undefined) { return; } - this.#handleModelUpdateForContentEditableElement(event, input, dataKey!); + this.#handleModelUpdateForContentEditableElement(event, input, dataKey as string); }; } diff --git a/packages/dom-adapters/src/CaretAdapter/index.ts b/packages/dom-adapters/src/CaretAdapter/index.ts index ce1c0c5d..49a24cc7 100644 --- a/packages/dom-adapters/src/CaretAdapter/index.ts +++ b/packages/dom-adapters/src/CaretAdapter/index.ts @@ -2,14 +2,12 @@ import { isNativeInput } from '@editorjs/dom'; import { type Caret, type CaretManagerEvents, - EditorJSModel, - EventType, Index, IndexBuilder, createDataKey, createBlockId } from '@editorjs/model'; -import type { CoreConfig } from '@editorjs/sdk'; +import type { CoreConfig, EditorAPI } from '@editorjs/sdk'; import { getAbsoluteRangeOffset, getBoundaryPointByAbsoluteOffset, @@ -33,9 +31,9 @@ export class CaretAdapter { #container: HTMLElement; /** - * Editor.js model + * Editor's API */ - #model: EditorJSModel; + #api: EditorAPI; /** * Shared inputs registry — single source of truth for (blockIndex, dataKey) → HTMLElement. @@ -55,19 +53,19 @@ export class CaretAdapter { /** * @param config - Editor's config - * @param model - Editor.js model + * @param api - Editor's API * @param registry - shared inputs registry */ constructor( @inject(TOKENS.EditorConfig) config: Required, - model: EditorJSModel, + @inject(TOKENS.EditorAPI) api: EditorAPI, registry: InputsRegistry ) { this.#config = config; - this.#model = model; + this.#api = api; this.#inputsRegistry = registry; this.#container = config.holder; - this.#currentUserCaret = this.#model.createCaret(this.#config.userId); + this.#currentUserCaret = this.#api.selection.createCaret(this.#config.userId); const { on } = useSelectionChange(); @@ -76,7 +74,7 @@ export class CaretAdapter { */ on(this.#container, selection => this.#onSelectionChange(selection), this); - this.#model.addEventListener(EventType.CaretManagerUpdated, event => this.#onModelCaretUpdate(event)); + this.#api.selection.onCaretUpdate(event => this.#onModelCaretUpdate(event)); } /** @@ -98,7 +96,7 @@ export class CaretAdapter { return; } - const caretToUpdate = this.#model.getCaret(userId); + const caretToUpdate = this.#api.selection.getCaret(userId); if (caretToUpdate === undefined) { return; @@ -115,13 +113,13 @@ export class CaretAdapter { * @returns input element or undefined if not found */ public findInput(blockIndex: number, dataKeyRaw: string): HTMLElement | undefined { - const blockId = this.#model.getBlockId(blockIndex); + const blockId = this.#api.blocks.getIdByIndex(blockIndex); if (blockId === undefined) { return undefined; } - return this.#inputsRegistry.getInput(blockId, createDataKey(dataKeyRaw)); + return this.#inputsRegistry.getInput(blockId, dataKeyRaw); } /** @@ -213,7 +211,7 @@ export class CaretAdapter { continue; } - const blockIndex = this.#model.getBlockIndexById(createBlockId(blockId)); + const blockIndex = this.#api.blocks.getIndexById(createBlockId(blockId)); if (blockIndex === -1) { continue; diff --git a/packages/dom-adapters/src/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index 25221543..5c70eb81 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -4,16 +4,14 @@ import type { ModelEvents } from '@editorjs/model'; import { - createInlineToolName, - EditorJSModel + createInlineToolName } from '@editorjs/model'; import { - EventType, TextFormattedEvent, TextUnformattedEvent } from '@editorjs/model'; import { CaretAdapter } from '../CaretAdapter/index.js'; -import type { CoreConfig, InlineTool, ToolLoadedCoreEvent } from '@editorjs/sdk'; +import type { CoreConfig, EditorAPI, InlineTool, ToolLoadedCoreEvent } from '@editorjs/sdk'; import { CoreEventType, EventBus } from '@editorjs/sdk'; import { surround } from '../utils/surround.js'; import { getBoundaryPointByAbsoluteOffset } from '../utils/getRelativeIndex.js'; @@ -28,9 +26,9 @@ import { TOKENS } from '../tokens.js'; @injectable() export class FormattingAdapter { /** - * Editor model instance + * Editor's API */ - #model: EditorJSModel; + #api: EditorAPI; /** * Tools, attached to the inline tool adapter @@ -49,18 +47,18 @@ export class FormattingAdapter { /** * @param config - Editor's config - * @param model - editor model instance + * @param api - Editor's API * @param caretAdapter - caret adapter instance * @param eventBus - Editor's EventBus instance */ constructor( @inject(TOKENS.EditorConfig) config: Required, - model: EditorJSModel, + @inject(TOKENS.EditorAPI) api: EditorAPI, caretAdapter: CaretAdapter, eventBus: EventBus ) { this.#config = config; - this.#model = model; + this.#api = api; this.#caretAdapter = caretAdapter; /** @@ -82,7 +80,7 @@ export class FormattingAdapter { /** * Add event listener for model changes */ - this.#model.addEventListener(EventType.Changed, (event: ModelEvents) => this.#handleModelUpdates(event)); + this.#api.document.onUpdate((event: ModelEvents) => this.#handleModelUpdates(event)); } /** @@ -157,7 +155,7 @@ export class FormattingAdapter { const rangeStart = Math.max(0, textRange[0] - 1); const rangeEnd = inputContent !== null ? Math.min(inputContent.length, textRange[1] + 1) : 0; - const affectedFragments = this.#model.getFragments(blockIndex, dataKey, rangeStart, rangeEnd); + const affectedFragments = this.#api.text.getFragments(blockIndex, dataKey as string, rangeStart, rangeEnd); const leftBoundary = affectedFragments[0]?.range[0] ?? textRange[0]; let rightBoundary = textRange[1]; diff --git a/packages/dom-adapters/src/InputsRegistry/index.ts b/packages/dom-adapters/src/InputsRegistry/index.ts index 037ecb43..2fe90d89 100644 --- a/packages/dom-adapters/src/InputsRegistry/index.ts +++ b/packages/dom-adapters/src/InputsRegistry/index.ts @@ -12,7 +12,7 @@ export class InputsRegistry { /** * Key = block id. Each entry is a (dataKey → element) map for that block. */ - #inputs: Map> = new Map(); + #inputs: Map> = new Map(); /** * Registers (or replaces) an input element for a given block + data key. @@ -20,7 +20,7 @@ export class InputsRegistry { * @param dataKey - data key of the input within the block * @param element - the DOM element to register */ - public register(blockId: BlockId, dataKey: DataKey, element: HTMLElement): void { + public register(blockId: BlockId, dataKey: string, element: HTMLElement): void { let blockMap = this.#inputs.get(blockId); if (blockMap === undefined) { @@ -37,7 +37,7 @@ export class InputsRegistry { * @param blockId - unique id of the block * @param dataKey - optional specific data key to unregister */ - public unregister(blockId: BlockId, dataKey?: DataKey): void { + public unregister(blockId: BlockId, dataKey?: string): void { if (dataKey === undefined) { this.#inputs.delete(blockId); @@ -52,7 +52,7 @@ export class InputsRegistry { * @param blockId - unique id of the block * @param dataKey - data key of the input */ - public getInput(blockId: BlockId, dataKey: DataKey): HTMLElement | undefined { + public getInput(blockId: BlockId, dataKey: string): HTMLElement | undefined { return this.#inputs.get(blockId)?.get(dataKey); } @@ -60,7 +60,7 @@ export class InputsRegistry { * Returns all inputs for a block as a (dataKey → element) map. * @param blockId - unique id of the block */ - public getBlockInputs(blockId: BlockId): Map | undefined { + public getBlockInputs(blockId: BlockId): Map | undefined { return this.#inputs.get(blockId); } @@ -69,7 +69,7 @@ export class InputsRegistry { * Useful for CaretAdapter to iterate all inputs during selection mapping. * @yields */ - public *entries(): Iterable<[BlockId, DataKey, HTMLElement]> { + public *entries(): Iterable<[BlockId, string, HTMLElement]> { for (const [blockId, keyMap] of this.#inputs) { for (const [dataKey, element] of keyMap) { yield [blockId, dataKey, element]; diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index 5dbc2b43..10255223 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,14 +1,14 @@ import 'reflect-metadata'; import type { BlockToolAdapter, - EditorJSAdapterPlugin, EditorjsAdapterPluginConstructor, - EditorjsAdapterPluginParams + EditorJSAdapterPlugin, + EditorjsAdapterPluginConstructor, + EditorjsPluginParams } from '@editorjs/sdk'; import { EventBus } from '@editorjs/sdk'; import { PluginType } from '@editorjs/sdk'; import { DOMBlockToolAdapter } from './BlockToolAdapter/index.js'; import { InputsRegistry } from './InputsRegistry/index.js'; -import { EditorJSModel } from '@editorjs/model'; import type { BlockId } from '@editorjs/model'; import { Container } from 'inversify'; import { TOKENS } from './tokens.js'; @@ -38,13 +38,13 @@ export class DOMAdapters implements EditorJSAdapterPlugin { /** * @param params - Plugin parameters * @param params.config - Editor's config - * @param params.model - Model instance + * @param params.api - Editor's API * @param params.eventBus - EventBus instance */ - constructor({ config, model, eventBus }: EditorjsAdapterPluginParams) { + constructor({ config, api, eventBus }: EditorjsPluginParams) { this.#iocContainer.bind>(TOKENS.EditorConfig).toConstantValue(config as Required); - this.#iocContainer.bind(EditorJSModel).toConstantValue(model); this.#iocContainer.bind(EventBus).toConstantValue(eventBus); + this.#iocContainer.bind(TOKENS.EditorAPI).toConstantValue(api); this.#iocContainer .bind(DOMBlockToolAdapter) .toSelf() diff --git a/packages/dom-adapters/src/tokens.ts b/packages/dom-adapters/src/tokens.ts index c547ca63..4a4d27a3 100644 --- a/packages/dom-adapters/src/tokens.ts +++ b/packages/dom-adapters/src/tokens.ts @@ -8,4 +8,9 @@ export const TOKENS = { */ // eslint-disable-next-line @typescript-eslint/naming-convention EditorConfig: Symbol.for('EditorConfig'), + /** + * Editor API token + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + EditorAPI: Symbol.for('EditorAPI'), } as const; diff --git a/packages/model/src/EditorJSModel.ts b/packages/model/src/EditorJSModel.ts index a9a7f97b..b33af370 100644 --- a/packages/model/src/EditorJSModel.ts +++ b/packages/model/src/EditorJSModel.ts @@ -302,14 +302,12 @@ export class EditorJSModel extends EventBus { /** * Returns a data node by the block index and key - * @param _userId - user identifier which is being set to the context * @param parameters - getDataNode method parameters * @param parameters.blockIndex - index of the BlockNode where data node is stored * @param parameters.dataKey - key of the node to get */ - @WithContext - public getDataNode(_userId: string | number, ...parameters: Parameters): ReturnType { - return this.#document.getDataNode(...parameters); + public getDataNode(...parameters: Parameters): ReturnType { + return this.#document.getDataNode(...parameters); } /** diff --git a/packages/model/src/EventBus/types/EventType.ts b/packages/model/src/EventBus/types/EventType.ts index 1c306fbf..6124d905 100644 --- a/packages/model/src/EventBus/types/EventType.ts +++ b/packages/model/src/EventBus/types/EventType.ts @@ -5,10 +5,10 @@ export enum EventType { /** * The document model has been changed */ - Changed = 'changed', + Changed = 'model:changed', /** * The position of caret has been updated */ - CaretManagerUpdated = 'caret-updated' + CaretManagerUpdated = 'model:caret-updated' } diff --git a/packages/model/src/entities/BlockNode/index.ts b/packages/model/src/entities/BlockNode/index.ts index c0b11e11..d8ca4a39 100644 --- a/packages/model/src/entities/BlockNode/index.ts +++ b/packages/model/src/entities/BlockNode/index.ts @@ -170,7 +170,7 @@ export class BlockNode extends EventBus { * Returns data node by the key * @param dataKey - key of the node to get */ - public getDataNode(dataKey: DataKey): ValueSerialized | TextNodeSerialized | undefined { + public getDataNode(dataKey: DataKey): ValueSerialized | TextNodeSerialized | undefined { const node = get(this.data, dataKey as string); if (node === undefined) { diff --git a/packages/model/src/entities/EditorDocument/index.ts b/packages/model/src/entities/EditorDocument/index.ts index ec184c02..f02eb90a 100644 --- a/packages/model/src/entities/EditorDocument/index.ts +++ b/packages/model/src/entities/EditorDocument/index.ts @@ -260,12 +260,12 @@ export class EditorDocument extends EventBus { * @param indexOrId - block index or block id where data node is stored * @param key - data key of the data node */ - public getDataNode(indexOrId: BlockIndexOrId, key: DataKey | string): ValueSerialized | TextNodeSerialized | undefined { + public getDataNode(indexOrId: BlockIndexOrId, key: DataKey | string): ValueSerialized | TextNodeSerialized | undefined { const resolvedIndex = this.#resolveBlockIndex(indexOrId); this.#checkIndexOutOfBounds(resolvedIndex, this.length - 1); - return this.#children[resolvedIndex].getDataNode(createDataKey(key)); + return this.#children[resolvedIndex].getDataNode(createDataKey(key)); } /** diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index a98f9340..4b14b1b1 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -1,5 +1,11 @@ import type { BlockToolData } from '@editorjs/editorjs'; -import type { BlockNodeInit, EditorDocumentSerialized } from '@editorjs/model'; +import type { + BlockId, + BlockNodeInit, + EditorDocumentSerialized, + TextNodeSerialized, + ValueSerialized +} from '@editorjs/model'; /** * Blocks API interface @@ -45,9 +51,9 @@ export interface BlocksAPI { /** * Removes Block by index, or current block if index is not passed - * @param index - index of a block to delete + * @param indexOrId - index or id of a block to delete */ - delete(index?: number): void; + delete(indexOrId?: number | string): void; /** * Moves a block to a new index @@ -100,6 +106,48 @@ export interface BlocksAPI { index?: number, ): void; // BlockAPI[]; + /** + * Returns block's index by its id + * @param id - block id to get index for + */ + getIndexById(id: string): number; + + /** + * Returns block id by its index + * @param index - block index to get id for + */ + getIdByIndex(index: number): BlockId | undefined; + + /** + * Returns serialized data for provided data key + * @param indexOrId - index or id of the block + * @param dataKey - data key to get + */ + getData(indexOrId: number | string, dataKey: string): TextNodeSerialized | ValueSerialized | undefined; + + /** + * Removes data by the data key + * @param indexOrId - index or id of the block + * @param dataKey - data key to remove + */ + removeData(indexOrId: number | string, dataKey: string): void; + + /** + * Creates data node with the given key + * @param indexOrId - index or id of the block + * @param dataKey - data key to create + * @param [initialData] - optional initial data + */ + createData(indexOrId: number | string, dataKey: string, initialData?: TextNodeSerialized | ValueSerialized): void; + + /** + * Updates value by the given key + * @param indexOrId - index or id of the block + * @param dataKey - data key to update + * @param value - new value + */ + updateValue(indexOrId: number | string, dataKey: string, value: V): void; + /** * Creates data of an empty block with a passed type. * @param toolName - block tool name diff --git a/packages/sdk/src/api/DocumentAPI.ts b/packages/sdk/src/api/DocumentAPI.ts index 603d05cf..6c26bbf2 100644 --- a/packages/sdk/src/api/DocumentAPI.ts +++ b/packages/sdk/src/api/DocumentAPI.ts @@ -1,4 +1,4 @@ -import type { EditorDocumentSerialized } from '@editorjs/model'; +import type { EditorDocumentSerialized, ModelEvents } from '@editorjs/model'; /** * Document API interface @@ -9,4 +9,10 @@ export interface DocumentAPI { * Returns serialized document object */ get data(): EditorDocumentSerialized; + + /** + * Registers model's update callback. Returns a cleanup function + * @param callback - callback called on model update + */ + onUpdate(callback: (event: ModelEvents) => void): () => void; } diff --git a/packages/sdk/src/api/EditorAPI.ts b/packages/sdk/src/api/EditorAPI.ts index 27dd36b1..3b09ad39 100644 --- a/packages/sdk/src/api/EditorAPI.ts +++ b/packages/sdk/src/api/EditorAPI.ts @@ -1,6 +1,7 @@ import type { BlocksAPI } from './BlocksAPI.js'; import type { SelectionAPI } from './SelectionAPI.js'; import type { DocumentAPI } from './DocumentAPI.js'; +import type { TextAPI } from './TextAPI.js'; /** * Editor API interface @@ -21,4 +22,9 @@ export interface EditorAPI { * Document API instance to work with Editor's document */ document: DocumentAPI; + + /** + * Text API to work with the text content of the document + */ + text: TextAPI; } diff --git a/packages/sdk/src/api/SelectionAPI.ts b/packages/sdk/src/api/SelectionAPI.ts index 6e12990e..c7cfe305 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -1,4 +1,4 @@ -import type { InlineToolName } from '@editorjs/model'; +import type { Caret, CaretManagerEvents, InlineToolName } from '@editorjs/model'; /** * Selection API interface @@ -11,4 +11,22 @@ export interface SelectionAPI { * @param data - optional data for the inline tool */ applyInlineToolForCurrentSelection(tool: InlineToolName, data?: Record): void; + + /** + * Registers a callback for CaretManager updates. Returns a cleanup function + * @param callback - callback for CaretManager updates + */ + onCaretUpdate(callback: (event: CaretManagerEvents) => void): () => void; + + /** + * Creates a new caret for a user + * @param userId - user id. If not provided, creates for current user + */ + createCaret(userId?: string | number): Caret; + + /** + * Returns user caret + * @param userId - user id. If not provided, returns for current user + */ + getCaret(userId?: string | number): Caret | undefined; } diff --git a/packages/sdk/src/api/TextAPI.ts b/packages/sdk/src/api/TextAPI.ts new file mode 100644 index 00000000..2d95f9ab --- /dev/null +++ b/packages/sdk/src/api/TextAPI.ts @@ -0,0 +1,96 @@ +import type { + InlineFragment +} from '@editorjs/model'; + +/** + * Editor's TextAPI to work with text content of the document + */ +export interface TextAPI { + /** + * Inserts text at a given position + * @param text - new text to insert + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - start offset + */ + insert( + text: string, + blockIndexOrId: number | string, + dataKey: string, + start?: number + ): void; + + /** + * Removes text from a given range + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + */ + remove( + blockIndexOrId: number | string, + dataKey: string, + start?: number, + end?: number + ): string; + + /** + * Formats the given range + * @param tool - tool to apply + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + * @param data - optional tool's data + */ + format( + tool: string, + blockIndexOrId: number | string, + dataKey: string, + start: number, + end: number, + data?: Record, + ): void; + + /** + * Unformats the given range + * @param tool - tool to remove + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + */ + unformat( + tool: string, + blockIndexOrId: number | string, + dataKey: string, + start: number, + end: number + ): void; + + /** + * Returns applied inline fragments for a given range + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + * @param start - range start + * @param end - range end + * @param tool - optional filter tool. If provided, will return only fragments of the given tool + */ + getFragments( + blockIndexOrId: number | string, + dataKey: string, + start?: number, + end?: number, + tool?: string + ): InlineFragment[]; + + /** + * Returns text content of the text node + * @param blockIndexOrId - block index or identifier + * @param dataKey - data key of the text node + */ + get( + blockIndexOrId: number | string, + dataKey: string + ): string; +} diff --git a/packages/sdk/src/api/index.ts b/packages/sdk/src/api/index.ts index 3e77540f..23872b60 100644 --- a/packages/sdk/src/api/index.ts +++ b/packages/sdk/src/api/index.ts @@ -2,3 +2,4 @@ export type * from './EditorAPI.js'; export type * from './BlocksAPI.js'; export type * from './SelectionAPI.js'; export type * from './DocumentAPI.js'; +export type * from './TextAPI.js'; diff --git a/packages/sdk/src/entities/BlockToolAdapter.ts b/packages/sdk/src/entities/BlockToolAdapter.ts index e31cb47e..10059cb4 100644 --- a/packages/sdk/src/entities/BlockToolAdapter.ts +++ b/packages/sdk/src/entities/BlockToolAdapter.ts @@ -1,24 +1,23 @@ -import type { BlockId, DataKey, EditorJSModel, EventBus, ModelEvents, TextNodeSerialized, ValueSerialized } from '@editorjs/model'; +import type { BlockId, EventBus, ModelEvents, TextNodeSerialized, ValueSerialized } from '@editorjs/model'; import { - createDataKey, DataNodeAddedEvent, DataNodeRemovedEvent, - EventType, NODE_TYPE_HIDDEN_PROP, ValueModifiedEvent, BlockChildType } from '@editorjs/model'; import type { CoreConfig } from '@/entities/Config'; import { KeyAddedEvent, KeyRemovedEvent, ValueNodeChangedEvent } from './EventBus/events/adapter/index.js'; +import type { EditorAPI } from '../api/index.js'; /** * Abstract BlockToolAdapter class implementing core functionality of the block adapter */ export abstract class BlockToolAdapter extends EventTarget { /** - * Model instance + * Editor's API */ - protected model: EditorJSModel; + #api: EditorAPI; /** * Unique identifier of the block that this adapter is connected to @@ -38,22 +37,23 @@ export abstract class BlockToolAdapter extends EventTarget { /** * Stored reference to the model change listener so it can be removed on destroy. */ - #modelChangeListener: EventListener; + #modelChangeListenerCleanup: () => void; /** * @param config - editor's configuration - * @param model - model instance + * @param api - Editor's API * @param eventBus - global event bus instance */ - constructor(config: Required, model: EditorJSModel, eventBus: EventBus) { + constructor(config: Required, api: EditorAPI, eventBus: EventBus) { super(); - this.model = model; + this.#api = api; this.config = config; this.eventBus = eventBus; - this.#modelChangeListener = ((event: ModelEvents) => this.#handleModelUpdate(event)) as EventListener; - this.model.addEventListener(EventType.Changed, this.#modelChangeListener); + this.#modelChangeListenerCleanup = this.#api.document.onUpdate( + ((event: ModelEvents) => this.#handleModelUpdate(event)) as EventListener + ); } /** @@ -63,7 +63,7 @@ export abstract class BlockToolAdapter extends EventTarget { * call `super.destroy()`, and then remove their own listeners. */ public destroy(): void { - this.model.removeEventListener(EventType.Changed, this.#modelChangeListener); + this.#modelChangeListenerCleanup(); } /** @@ -81,62 +81,52 @@ export abstract class BlockToolAdapter extends EventTarget { return this.blockId; } - /** - * @deprecated Use {@link setBlockId} + {@link getBlockId} instead. - * Kept temporarily for backward compatibility while callers are migrated. - * Updates the internal block index (derived on demand from the model). - * @param index - new block index value - */ - public setBlockIndex(index: number): void { - void index; // no-op – adapters are now addressed by blockId - } - /** * @deprecated Use {@link getBlockId} instead. * Returns the current block index by asking the model. */ public getBlockIndex(): number { - return this.model.getBlockIndexById(this.blockId); + return this.#api.blocks.getIndexById(this.blockId); } /** * Creates data node for the text input key - * @param keyRaw - input key within the block + * @param key - input key within the block * @param initialData - optional initial data for the block */ - public registerTextInputKey(keyRaw: string, initialData?: Pick & Partial): void { + public registerTextInputKey(key: string, initialData?: Pick & Partial): void { const data: TextNodeSerialized = { value: initialData?.value ?? '', fragments: initialData?.fragments ?? [], [NODE_TYPE_HIDDEN_PROP]: BlockChildType.Text, }; - this.#createDataNode(createDataKey(keyRaw), data); + this.#createDataNode(key, data); } /** * Creates data node for the value key. Returns an update function which could be called to update value in the model - * @param keyRaw - value key within the block + * @param key - value key within the block * @param initialData - optional initial data for the value */ - public registerValueKey(keyRaw: string, initialData?: ValueSerialized): (newValue: V) => void { - this.#createDataNode(createDataKey(keyRaw), initialData); + public registerValueKey(key: string, initialData?: ValueSerialized): (newValue: V) => void { + this.#createDataNode(key, initialData); return (newValue: V) => { - this.model.updateValue(this.config.userId, this.blockId, createDataKey(keyRaw), newValue); + this.#api.blocks.updateValue(this.blockId, key, newValue); }; } /** * Remove data node by the key - * @param keyRaw - key of the node to remove + * @param key - key of the node to remove */ - public removeKey(keyRaw: string): void { - if (this.model.getDataNode(this.config.userId, this.blockId, keyRaw) === undefined) { + public removeKey(key: string): void { + if (this.#api.blocks.getData(this.blockId, key) === undefined) { return; } - this.model.removeDataNode(this.config.userId, this.blockId, createDataKey(keyRaw)); + this.#api.blocks.removeData(this.blockId, key); } /** @@ -150,12 +140,12 @@ export abstract class BlockToolAdapter extends EventTarget { * // Register a value key in an array (e.g. for items[0].content) * this.#createDataNode(createDataKey('items[0].content'), { $t: 'v', value: 'Item text' }); */ - #createDataNode(key: DataKey, initialData?: TextNodeSerialized | ValueSerialized): void { - if (this.model.getDataNode(this.config.userId, this.blockId, key) !== undefined) { + #createDataNode(key: string, initialData?: TextNodeSerialized | ValueSerialized): void { + if (this.#api.blocks.getData(this.blockId, key) !== undefined) { return; } - this.model.createDataNode(this.config.userId, this.blockId, key, initialData); + this.#api.blocks.createData(this.blockId, key, initialData); } /** @@ -169,7 +159,7 @@ export abstract class BlockToolAdapter extends EventTarget { return; } - const eventBlockId = this.model.getBlockId(blockIndex); + const eventBlockId = this.#api.blocks.getIdByIndex(blockIndex); if (eventBlockId !== this.blockId) { return; diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts index a2c7d35f..2e6696aa 100644 --- a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts +++ b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts @@ -1,19 +1,8 @@ -import type { EditorjsPlugin, EditorjsPluginConstructor, EditorjsPluginParams } from '@/entities/EditorjsPlugin'; -import type { BlockId, EditorJSModel } from '@editorjs/model'; +import type { EditorjsPlugin, EditorjsPluginConstructor } from '@/entities/EditorjsPlugin'; +import type { BlockId } from '@editorjs/model'; import type { PluginType } from '@/entities/EntityType'; import type { BlockToolAdapter } from '@/entities/BlockToolAdapter'; -/** - * Parameters for adapter plugin constructor. - * Extends standard plugin params with direct model access for low-level operations. - */ -export interface EditorjsAdapterPluginParams extends EditorjsPluginParams { - /** - * Editor's document model instance - */ - model: EditorJSModel; -} - /** * Base interface for adapter plugins */ @@ -37,7 +26,7 @@ export interface EditorJSAdapterPlugin extends EditorjsPlugin { /** * Constructor type for adapter plugins */ -export interface EditorjsAdapterPluginConstructor extends EditorjsPluginConstructor { +export interface EditorjsAdapterPluginConstructor extends EditorjsPluginConstructor { /** * Marks the plugin as a singleton adapter, replaceable via core.use() */ diff --git a/packages/sdk/src/entities/EditorjsPlugin.ts b/packages/sdk/src/entities/EditorjsPlugin.ts index 84045a57..fe4085af 100644 --- a/packages/sdk/src/entities/EditorjsPlugin.ts +++ b/packages/sdk/src/entities/EditorjsPlugin.ts @@ -37,10 +37,6 @@ export interface EditorjsPlugin { * Constructor type for EditorjsPlugin */ export interface EditorjsPluginConstructor< - /** - * Plugin's params. Has to be a generic param as constructor can not be overloaded - */ - Params extends EditorjsPluginParams = EditorjsPluginParams, /** * Plugin's instance interface. Has to be a generic param as constructor can not be overloaded */ @@ -49,7 +45,7 @@ export interface EditorjsPluginConstructor< /** * Create new EditorjsPlugin instance */ - new (params: Params): Instance; + new (params: EditorjsPluginParams): Instance; /** * Plugin's entity type: UI plugin, Tool, etc. From 3c6f1063030dd4695b364291ad18ca517076f299 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 14 May 2026 00:25:35 +0100 Subject: [PATCH 02/10] Fix models tests --- packages/model/src/EditorJSModel.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/model/src/EditorJSModel.spec.ts b/packages/model/src/EditorJSModel.spec.ts index 0e65a8c6..240496b1 100644 --- a/packages/model/src/EditorJSModel.spec.ts +++ b/packages/model/src/EditorJSModel.spec.ts @@ -237,7 +237,7 @@ describe('EditorJSModel', () => { // DataNodeAdded events are queued as microtasks, flush before asserting await Promise.resolve(); - const node = model.getDataNode(userId, 0, 'text'); + const node = model.getDataNode(0, 'text'); expect(node).toBeDefined(); }); @@ -245,7 +245,7 @@ describe('EditorJSModel', () => { it('should return undefined for a non-existent key', async () => { await Promise.resolve(); - const node = model.getDataNode(userId, 0, 'nonexistent'); + const node = model.getDataNode(0, 'nonexistent'); expect(node).toBeUndefined(); }); From c2ba5b2d39ab1e78467b5e31118c2b4649474cbc Mon Sep 17 00:00:00 2001 From: gohabereg Date: Thu, 14 May 2026 18:50:23 +0100 Subject: [PATCH 03/10] Update types for delete block method --- packages/core/src/api/BlocksAPI.ts | 6 +++--- packages/core/src/components/BlockManager.ts | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 328c0c3d..dcf5fd75 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -68,10 +68,10 @@ export class BlocksAPI implements BlocksApiInterface { /** * Removes Block by index, or current block if index is not passed - * @param index - index of a block to delete + * @param indexOrId - index or identifier of a block to delete */ - public delete(index?: number): void { - return this.#blocksManager.deleteBlock(index); + public delete(indexOrId?: number): void { + return this.#blocksManager.deleteBlock(indexOrId); } /** diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 7cfd61c2..710a40d8 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -1,4 +1,5 @@ import { + BlockIndexOrId, type BlockNodeInit, type EditorDocumentSerialized, EditorJSModel @@ -171,17 +172,17 @@ export class BlocksManager { /** * Removes Block by index, or current block if index is not passed - * @param index - index of a block to delete + * @param indexOrId - index or identifier of a block to delete */ - public deleteBlock(index: number | undefined = this.#getCurrentBlockIndex()): void { - if (index === undefined) { + public deleteBlock(indexOrId: number | string | undefined = this.#getCurrentBlockIndex()): void { + if (indexOrId === undefined) { /** * @todo see what happens in legacy */ throw new Error('No block selected to delete'); } - this.#model.removeBlock(this.#config.userId, index); + this.#model.removeBlock(this.#config.userId, indexOrId as BlockIndexOrId); } /** From 475dde64a320ffb48dc519caddae0a45edddc0f1 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Fri, 15 May 2026 20:55:40 +0100 Subject: [PATCH 04/10] Change API methods to accept a single params object --- .../src/api/BlocksAPI.integration.spec.ts | 200 ++++++++++++------ packages/core/src/api/BlocksAPI.spec.ts | 28 +-- packages/core/src/api/BlocksAPI.ts | 113 +++++----- packages/core/src/api/SelectionAPI.spec.ts | 7 +- packages/core/src/api/SelectionAPI.ts | 11 +- packages/core/src/api/TextAPI.ts | 126 +++++------ packages/core/src/plugins/ShortcutsPlugin.ts | 3 +- .../src/BlockToolAdapter/index.ts | 61 ++++-- .../src/FormattingAdapter/index.ts | 7 +- packages/sdk/src/api/BlocksAPI.ts | 132 ++++++++---- packages/sdk/src/api/SelectionAPI.ts | 10 +- packages/sdk/src/api/TextAPI.ts | 142 +++++++------ packages/sdk/src/entities/BlockToolAdapter.ts | 27 ++- .../ui/src/InlineToolbar/InlineToolbar.ts | 7 +- packages/ui/src/Toolbox/Toolbox.ts | 12 +- 15 files changed, 523 insertions(+), 363 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index fd23bbab..b6435369 100644 --- a/packages/core/src/api/BlocksAPI.integration.spec.ts +++ b/packages/core/src/api/BlocksAPI.integration.spec.ts @@ -95,7 +95,10 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('insert()', () => { it('should add a block to an empty document and model.length becomes 1', () => { - blocksAPI.insert('paragraph', {}); + blocksAPI.insert({ + type: 'paragraph', + data: {}, + }); expect(model.length).toBe(1); expect(model.serialized.blocks[0]).toEqual( @@ -104,10 +107,14 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should insert a block at the specified index', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'paragraph' }); - blocksAPI.insert('header', { text: 'Title' }, 1); + blocksAPI.insert({ + type: 'header', + data: { text: 'Title' }, + index: 1, + }); expect(model.length).toBe(3); expect(model.serialized.blocks[1]).toEqual( @@ -125,10 +132,15 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should replace a block at the given index when replace flag is set', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('paragraph'); - - blocksAPI.insert('header', {}, 0, undefined, true); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.insert({ + type: 'header', + data: {}, + index: 0, + replace: true, + }); expect(model.length).toBe(2); expect(model.serialized.blocks[0]).toEqual( @@ -139,10 +151,10 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('insertMany()', () => { it('should insert multiple blocks at the specified index', () => { - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); - blocksAPI.insertMany( - [ + blocksAPI.insertMany({ + blocks: [ { name: 'header', data: {}, @@ -152,8 +164,8 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { data: {}, }, ], - 0 - ); + index: 0, + }); expect(model.length).toBe(3); expect(model.serialized.blocks[0]).toEqual(expect.objectContaining({ name: 'header' })); @@ -162,18 +174,20 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should append blocks at the end when index is omitted', () => { - blocksAPI.insert('paragraph'); - - blocksAPI.insertMany([ - { - name: 'header', - data: {}, - }, - { - name: 'list', - data: {}, - }, - ]); + blocksAPI.insert({ type: 'paragraph' }); + + blocksAPI.insertMany({ + blocks: [ + { + name: 'header', + data: {}, + }, + { + name: 'list', + data: {}, + }, + ], + }); expect(model.length).toBe(3); expect(model.serialized.blocks[1]).toEqual(expect.objectContaining({ name: 'header' })); @@ -183,11 +197,17 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('delete()', () => { it('should remove a block at the given index', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('header', {}, 1); - blocksAPI.insert('list', {}, 2); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ + type: 'header', + index: 1, + }); + blocksAPI.insert({ + type: 'list', + index: 2, + }); - blocksAPI.delete(1); + blocksAPI.delete({ block: 1 }); expect(model.length).toBe(2); expect(model.serialized.blocks[0]).toEqual(expect.objectContaining({ name: 'paragraph' })); @@ -195,17 +215,20 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should remove the first block when index is 0', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('header', {}, 1); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ + type: 'header', + index: 1, + }); - blocksAPI.delete(0); + blocksAPI.delete({ block: 0 }); expect(model.length).toBe(1); expect(model.serialized.blocks[0]).toEqual(expect.objectContaining({ name: 'header' })); }); it('should throw when no index is provided and no caret is set', () => { - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); expect(() => blocksAPI.delete()).toThrow('No block selected to delete'); }); @@ -213,39 +236,63 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('move()', () => { it('should move a block from fromIndex to toIndex (forward)', () => { - blocksAPI.insert('a'); - blocksAPI.insert('b', {}, 1); - blocksAPI.insert('c', {}, 2); + blocksAPI.insert({ type: 'a' }); + blocksAPI.insert({ + type: 'b', + index: 1, + }); + blocksAPI.insert({ + type: 'c', + index: 2, + }); // Move block 0 ("a") to index 2 - blocksAPI.move(2, 0); + blocksAPI.move({ + toIndex: 2, + fromIndex: 0, + }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['b', 'c', 'a']); }); it('should move a block from fromIndex to toIndex (backward)', () => { - blocksAPI.insert('a'); - blocksAPI.insert('b', {}, 1); - blocksAPI.insert('c', {}, 2); + blocksAPI.insert({ type: 'a' }); + blocksAPI.insert({ + type: 'b', + index: 1, + }); + blocksAPI.insert({ + type: 'c', + index: 2, + }); - blocksAPI.move(0, 2); + blocksAPI.move({ + toIndex: 0, + fromIndex: 2, + }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['c', 'a', 'b']); }); it('should not change anything when fromIndex equals toIndex', () => { - blocksAPI.insert('a'); - blocksAPI.insert('b', {}, 1); + blocksAPI.insert({ type: 'a' }); + blocksAPI.insert({ + type: 'b', + index: 1, + }); - blocksAPI.move(0, 0); + blocksAPI.move({ + toIndex: 0, + fromIndex: 0, + }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['a', 'b']); }); it('should throw when no fromIndex is provided and no caret is set', () => { - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); - expect(() => blocksAPI.move(0)).toThrow('No block selected to move'); + expect(() => blocksAPI.move({ toIndex: 0 })).toThrow('No block selected to move'); }); }); @@ -255,13 +302,13 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should return the correct count after insertions and deletions', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('paragraph'); - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'paragraph' }); expect(blocksAPI.getBlocksCount()).toBe(3); - blocksAPI.delete(0); + blocksAPI.delete({ block: 0 }); expect(blocksAPI.getBlocksCount()).toBe(2); }); @@ -269,8 +316,8 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('render()', () => { it('should replace document content with the provided serialized data', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ type: 'paragraph' }); blocksAPI.render({ identifier: 'new-doc', @@ -289,7 +336,7 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should result in an empty document when empty blocks array is passed', () => { - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); blocksAPI.render({ identifier: 'empty-doc', @@ -303,9 +350,15 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('clear()', () => { it('should remove all blocks from the document', () => { - blocksAPI.insert('paragraph'); - blocksAPI.insert('header', {}, 1); - blocksAPI.insert('list', {}, 2); + blocksAPI.insert({ type: 'paragraph' }); + blocksAPI.insert({ + type: 'header', + index: 1, + }); + blocksAPI.insert({ + type: 'list', + index: 2, + }); blocksAPI.clear(); @@ -326,7 +379,7 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { model.addEventListener(EventType.Changed, handler); - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); expect(handler).toHaveBeenCalled(); @@ -334,13 +387,13 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should emit BlockRemovedEvent on model when delete is called', async () => { - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); const handler = jest.fn(); model.addEventListener(EventType.Changed, handler); - blocksAPI.delete(0); + blocksAPI.delete({ block: 0 }); await Promise.resolve(); // flush queueMicrotask used by removeBlock @@ -352,22 +405,33 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { describe('combined operations', () => { it('should handle a sequence of insert, move, delete, and clear', () => { - // Insert 3 blocks: a, b, c - blocksAPI.insert('a'); - blocksAPI.insert('b', {}, 1); - blocksAPI.insert('c', {}, 2); + blocksAPI.insert({ type: 'a' }); + blocksAPI.insert({ + type: 'b', + index: 1, + }); + blocksAPI.insert({ + type: 'c', + index: 2, + }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['a', 'b', 'c']); // Move c to front: c, a, b - blocksAPI.move(0, 2); + blocksAPI.move({ + toIndex: 0, + fromIndex: 2, + }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['c', 'a', 'b']); // Delete middle block (a): c, b - blocksAPI.delete(1); + blocksAPI.delete({ block: 1 }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['c', 'b']); // Insert d at index 1: c, d, b - blocksAPI.insert('d', {}, 1); + blocksAPI.insert({ + type: 'd', + index: 1, + }); expect(model.serialized.blocks.map(b => b.name)).toEqual(['c', 'd', 'b']); // Clear everything @@ -376,7 +440,7 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { }); it('should support render after clear and then further mutations', () => { - blocksAPI.insert('paragraph'); + blocksAPI.insert({ type: 'paragraph' }); blocksAPI.clear(); blocksAPI.render({ @@ -398,7 +462,7 @@ describe('BlocksAPI integration (real model, mocked DOM adapters)', () => { expect(model.length).toBe(2); - blocksAPI.delete(0); + blocksAPI.delete({ block: 0 }); expect(model.length).toBe(1); expect(model.serialized.blocks[0]).toEqual(expect.objectContaining({ name: 'list' })); diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index a0acb4b2..e0b56e8a 100644 --- a/packages/core/src/api/BlocksAPI.spec.ts +++ b/packages/core/src/api/BlocksAPI.spec.ts @@ -72,7 +72,7 @@ describe('BlocksAPI', () => { new EditorJSModel('userId', { identifier: 'docId' }) ); - api.delete(2); + api.delete({ block: 2 }); expect(blocksManager.deleteBlock).toHaveBeenCalledWith(2); }); @@ -98,7 +98,8 @@ describe('BlocksAPI', () => { new EditorJSModel('userId', { identifier: 'docId' }) ); - api.move(3, 1); + api.move({ toIndex: 3, + fromIndex: 1 }); expect(blocksManager.move).toHaveBeenCalledWith(3, 1); }); @@ -132,7 +133,10 @@ describe('BlocksAPI', () => { data: {}, }]; - api.insertMany(blocks as never, 4); + api.insertMany({ + blocks: blocks, + index: 4, + }); expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, 4); }); @@ -149,7 +153,7 @@ describe('BlocksAPI', () => { data: {}, }]; - api.insertMany(blocks as never); + api.insertMany({ blocks }); expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, undefined); }); @@ -180,14 +184,14 @@ describe('BlocksAPI', () => { new EditorJSModel('userId', { identifier: 'docId' }) ); - api.insert( - 'header', - { text: 'Title' }, - 2, - true, - true, - 'id-1' - ); + api.insert({ + type: 'header', + data: { text: 'Title' }, + index: 2, + focus: true, + replace: true, + id: 'id-1', + }); expect(blocksManager.insert).toHaveBeenCalledWith({ type: 'header', diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index dcf5fd75..33511cc2 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -2,16 +2,17 @@ import 'reflect-metadata'; import { inject, injectable } from 'inversify'; import { TOKENS } from '../tokens.js'; import { BlocksManager } from '../components/BlockManager.js'; -import { BlockToolData } from '@editorjs/editorjs'; import { CoreConfigValidated } from '@editorjs/sdk'; import { BlocksAPI as BlocksApiInterface } from '@editorjs/sdk'; import { - BlockId, BlockIndexOrId, - type BlockNodeInit, - createBlockId, createDataKey, - type EditorDocumentSerialized, + BlockId, + BlockIndexOrId, + createBlockId, + createDataKey, + EditorDocumentSerialized, EditorJSModel, - TextNodeSerialized, ValueSerialized + TextNodeSerialized, + ValueSerialized } from '@editorjs/model'; /** @@ -67,19 +68,21 @@ export class BlocksAPI implements BlocksApiInterface { } /** - * Removes Block by index, or current block if index is not passed - * @param indexOrId - index or identifier of a block to delete + * Removes Block by index or id, or current block if params are not passed + * @param params - delete parameters + * @param params.block - index or id of a block to delete */ - public delete(indexOrId?: number): void { - return this.#blocksManager.deleteBlock(indexOrId); + public delete({ block }: NonNullable[0]> = {}): void { + return this.#blocksManager.deleteBlock(block); } /** * Moves a block to a new index - * @param toIndex - index where the block is moved to - * @param [fromIndex] - block to move. Current block if not passed + * @param params - move parameters + * @param params.toIndex - index where the block is moved to + * @param [params.fromIndex] - block to move. Current block if not passed */ - public move(toIndex: number, fromIndex?: number): void { + public move({ toIndex, fromIndex }: Parameters[0]): void { return this.#blocksManager.move(toIndex, fromIndex); } @@ -92,31 +95,24 @@ export class BlocksAPI implements BlocksApiInterface { /** * Inserts several Blocks to specified index - * @param blocks - array of blocks to insert - * @param [index] - index to insert blocks at. If undefined, inserts at the end + * @param params - insertMany parameters + * @param params.blocks - array of blocks to insert + * @param [params.index] - index to insert blocks at. If undefined, inserts at the end */ - public insertMany(blocks: BlockNodeInit[], index?: number): void { + public insertMany({ blocks, index }: Parameters[0]): void { return this.#blocksManager.insertMany(blocks, index); } /** * Inserts a new block to the editor - * @param type - Block tool name to insert - * @param data - Block's initial data - * @param index - index to insert block at - * @param focus - flag indicates if new block should be focused @todo implement - * @param replace - flag indicates if block at index should be replaced @todo implement - * @param id - id of the inserted block @todo implement - */ - public insert( - type?: string, - data?: BlockToolData, - index?: number, - focus?: boolean, - replace?: boolean, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - id?: string - ): void { + * @param [params] - insert parameters + * @param [params.type] - Block tool name to insert + * @param [params.data] - Block's initial data + * @param [params.index] - index to insert block at + * @param [params.focus] - flag indicates if new block should be focused @todo implement + * @param [params.replace] - flag indicates if block at index should be replaced @todo implement + */ + public insert({ type, data, index, focus, replace }: NonNullable[0]> = {}): void { const blockType = type ?? this.#config.defaultBlock; const blockData = data ?? {}; @@ -147,51 +143,48 @@ export class BlocksAPI implements BlocksApiInterface { /** * Returns serialized data for provided data key - * @param blockIndexOrId - index or identifier of the block - * @param dataKey - data key to get serialized data for - */ - public getData(blockIndexOrId: number | string, dataKey: string): TextNodeSerialized | ValueSerialized | undefined { - /** - * Need an explicit cast here because TS doesn't pass generic for some reason - */ - return this.#model.getDataNode(blockIndexOrId as BlockIndexOrId, dataKey) as TextNodeSerialized | ValueSerialized | undefined; + * @param params - getData parameters + * @param params.block - index or id of the block + * @param params.key - data key to get serialized data for + */ + public getData({ block, key }: Parameters[0]): TextNodeSerialized | ValueSerialized | undefined { + return this.#model.getDataNode(block as BlockIndexOrId, key) as TextNodeSerialized | ValueSerialized | undefined; } /** * Creates data node with the given key - * @param indexOrId - index or id of the block - * @param dataKey - data key of the new data node - * @param [initialData] - optional initial data - */ - public createData( - indexOrId: number | string, - dataKey: string, - initialData?: TextNodeSerialized | ValueSerialized - ): void { + * @param params - createData parameters + * @param params.block - index or id of the block + * @param params.key - data key of the new data node + * @param [params.initialData] - optional initial data + */ + public createData({ block, key, initialData }: Parameters[0]): void { this.#model.createDataNode( this.#config.userId, - indexOrId as BlockIndexOrId, - dataKey, + block as BlockIndexOrId, + key, initialData ); } /** * Removes data by the data key - * @param blockIndexOrId - index or identifier of the block - * @param dataKey - data key of the node to remove + * @param params - removeData parameters + * @param params.block - index or identifier of the block + * @param params.key - data key of the node to remove */ - public removeData(blockIndexOrId: string | number, dataKey: string): void { - this.#model.removeDataNode(this.#config.userId, blockIndexOrId as BlockIndexOrId, dataKey); + public removeData({ block, key }: Parameters[0]): void { + this.#model.removeDataNode(this.#config.userId, block as BlockIndexOrId, key); } /** * Updates value by the given key - * @param blockIndexOrId - index or identifier of the block - * @param dataKey - key of the data node to update - * @param value - new value + * @param params - updateValue parameters + * @param params.block - index or identifier of the block + * @param params.key - key of the data node to update + * @param params.value - new value */ - public updateValue(blockIndexOrId: string | number, dataKey: string, value: V): void { - this.#model.updateValue(this.#config.userId, blockIndexOrId as BlockIndexOrId, createDataKey(dataKey), value); + public updateValue({ block, key, value }: Parameters[0]): void { + this.#model.updateValue(this.#config.userId, block as BlockIndexOrId, createDataKey(key), value); } } diff --git a/packages/core/src/api/SelectionAPI.spec.ts b/packages/core/src/api/SelectionAPI.spec.ts index 88ee3fe4..e01f09ab 100644 --- a/packages/core/src/api/SelectionAPI.spec.ts +++ b/packages/core/src/api/SelectionAPI.spec.ts @@ -25,7 +25,7 @@ describe('SelectionAPI', () => { // @ts-expect-error - mock object const selectionManager = new SelectionManager(); - describe('.applyInlineToolForCurrentSelection()', () => { + describe('.applyInlineTool()', () => { it('should convert toolName and delegate to SelectionManager', () => { const api = new SelectionAPI( selectionManager as unknown as InstanceType, @@ -33,7 +33,10 @@ describe('SelectionAPI', () => { {} as unknown as CoreConfigValidated ); - api.applyInlineToolForCurrentSelection('bold', { level: 1 }); + api.applyInlineTool({ + tool: 'bold', + data: { level: 1 }, + }); expect(createInlineToolName).toHaveBeenCalledWith('bold'); expect(selectionManager.applyInlineToolForCurrentSelection).toHaveBeenCalledWith('inline:bold', { level: 1 }); diff --git a/packages/core/src/api/SelectionAPI.ts b/packages/core/src/api/SelectionAPI.ts index 06b88022..f7bfa9f8 100644 --- a/packages/core/src/api/SelectionAPI.ts +++ b/packages/core/src/api/SelectionAPI.ts @@ -3,7 +3,7 @@ import { inject, injectable } from 'inversify'; import { SelectionManager } from '../components/SelectionManager.js'; import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType } from '@editorjs/model'; -import { CoreConfigValidated, InlineToolFormatData } from '@editorjs/sdk'; +import { CoreConfigValidated } from '@editorjs/sdk'; import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; import { TOKENS } from '../tokens'; @@ -35,11 +35,12 @@ export class SelectionAPI implements SelectionApiInterface { /** * Applies passed inline tool to the current selection - * @param toolName - Inline Tool name from the config to apply on the current selection - * @param data - Inline Tool data to apply to the current selection (eg. link data) + * @param params - methods parameters + * @param params.tool - Inline Tool name from the config to apply on the current selection + * @param [params.data] - Inline Tool data to apply to the current selection (e.g. link data) */ - public applyInlineToolForCurrentSelection(toolName: string, data?: InlineToolFormatData): void { - this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(toolName), data); + public applyInlineTool({ tool, data }: Parameters[0]): void { + this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(tool), data); } /** diff --git a/packages/core/src/api/TextAPI.ts b/packages/core/src/api/TextAPI.ts index ae3bd15a..1e51d74e 100644 --- a/packages/core/src/api/TextAPI.ts +++ b/packages/core/src/api/TextAPI.ts @@ -40,21 +40,17 @@ export class TextAPI implements TextAPIInterface { /** * Inserts text at a given position - * @param text - new text to insert - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - start offset + * @param params - insert parameters + * @param params.text - new text to insert + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - start offset */ - public insert( - text: string, - blockIndexOrId: number | string, - dataKey: string, - start?: number - ): void { + public insert({ text, block, key, start }: Parameters[0]): void { this.#model.insertText( this.#config.userId, - blockIndexOrId as BlockIndexOrId, - createDataKey(dataKey), + block as BlockIndexOrId, + createDataKey(key), text, start ); @@ -62,21 +58,17 @@ export class TextAPI implements TextAPIInterface { /** * Removes text from a given range. Returns removed text - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end + * @param params - remove parameters + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end */ - public remove( - blockIndexOrId: number | string, - dataKey: string, - start?: number, - end?: number - ): string { + public remove({ block, key, start, end }: Parameters[0]): string { return this.#model.removeText( this.#config.userId, - blockIndexOrId as BlockIndexOrId, - createDataKey(dataKey), + block as BlockIndexOrId, + createDataKey(key), start, end ); @@ -84,25 +76,19 @@ export class TextAPI implements TextAPIInterface { /** * Formats the given range - * @param tool - tool to apply - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end - * @param data - optional tool's data + * @param params - format parameters + * @param params.tool - tool to apply + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end + * @param params.data - optional tool's data */ - public format( - tool: string, - blockIndexOrId: number | string, - dataKey: string, - start: number, - end: number, - data?: Record - ): void { + public format({ tool, block, key, start, end, data }: Parameters[0]): void { this.#model.format( this.#config.userId, - blockIndexOrId as BlockIndexOrId, - createDataKey(dataKey), + block as BlockIndexOrId, + createDataKey(key), createInlineToolName(tool), start, end, @@ -112,23 +98,18 @@ export class TextAPI implements TextAPIInterface { /** * Unformats the given range - * @param tool - tool to remove - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end + * @param params - unformat parameters + * @param params.tool - tool to remove + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end */ - public unformat( - tool: string, - blockIndexOrId: number | string, - dataKey: string, - start: number, - end: number - ): void { + public unformat({ tool, block, key, start, end }: Parameters[0]): void { this.#model.unformat( this.#config.userId, - blockIndexOrId as BlockIndexOrId, - createDataKey(dataKey), + block as BlockIndexOrId, + createDataKey(key), createInlineToolName(tool), start, end @@ -137,22 +118,17 @@ export class TextAPI implements TextAPIInterface { /** * Returns applied inline fragments for a given range - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end - * @param tool - optional filter tool. If provided, will return only fragments of the given tool + * @param params - getFragments parameters + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end + * @param params.tool - optional filter tool. If provided, will return only fragments of the given tool */ - public getFragments( - blockIndexOrId: number | string, - dataKey: string, - start?: number, - end?: number, - tool?: string - ): InlineFragment[] { + public getFragments({ block, key, start, end, tool }: Parameters[0]): InlineFragment[] { return this.#model.getFragments( - blockIndexOrId as BlockIndexOrId, - createDataKey(dataKey), + block as BlockIndexOrId, + createDataKey(key), start, end, tool !== undefined ? createInlineToolName(tool) : undefined @@ -161,16 +137,14 @@ export class TextAPI implements TextAPIInterface { /** * Returns text content of the text node - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node + * @param params - get parameters + * @param params.block - block index or identifier + * @param params.key - data key of the text node */ - public get( - blockIndexOrId: number | string, - dataKey: string - ): string { + public get({ block, key }: Parameters[0]): string { return this.#model.getText( - blockIndexOrId as BlockIndexOrId, - createDataKey(dataKey) + block as BlockIndexOrId, + createDataKey(key) ); } } diff --git a/packages/core/src/plugins/ShortcutsPlugin.ts b/packages/core/src/plugins/ShortcutsPlugin.ts index 47251b04..df565fcf 100644 --- a/packages/core/src/plugins/ShortcutsPlugin.ts +++ b/packages/core/src/plugins/ShortcutsPlugin.ts @@ -1,4 +1,3 @@ -import type { InlineToolName } from '@editorjs/model'; import type { BlockToolFacade, BlockTuneFacade, @@ -100,7 +99,7 @@ export class ShortcutsPlugin implements EditorjsPlugin { */ #processInlineTool(toolName: string): void { try { - this.#api.selection.applyInlineToolForCurrentSelection(toolName as InlineToolName); + this.#api.selection.applyInlineTool({ tool: toolName }); } catch (error) { if (error instanceof IndexError) { /** diff --git a/packages/dom-adapters/src/BlockToolAdapter/index.ts b/packages/dom-adapters/src/BlockToolAdapter/index.ts index 9958bbaa..2c29efa7 100644 --- a/packages/dom-adapters/src/BlockToolAdapter/index.ts +++ b/packages/dom-adapters/src/BlockToolAdapter/index.ts @@ -50,6 +50,9 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #beforeInputListener: EventListener; + /** + * Editor API instance + */ #api: EditorAPI; /** @@ -134,8 +137,14 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { return; } - const value = this.#api.text.get(this.blockId, key); - const fragments = this.#api.text.getFragments(this.blockId, key); + const value = this.#api.text.get({ + block: this.blockId, + key, + }); + const fragments = this.#api.text.getFragments({ + block: this.blockId, + key, + }); this.#inputsRegistry.register(this.blockId, key, input); @@ -281,7 +290,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { * Middle block in a cross-input selection: remove the whole block, not the same as removeText(0, length). */ if (isInputInBetweenSelection(input, range)) { - this.#api.blocks.delete(this.blockId); + this.#api.blocks.delete({ block: this.blockId }); return; } @@ -309,7 +318,12 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { } const [start, end] = clipped; - const removedText = this.#api.text.remove(this.blockId, key, start, end); + const removedText = this.#api.text.remove({ + block: this.blockId, + key, + start, + end, + }); let newCaretIndex: number | null = null; @@ -375,7 +389,12 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (data !== undefined && input.contains(range.startContainer)) { start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.#api.text.insert(data, this.blockId, key, start); + this.#api.text.insert({ + text: data, + block: this.blockId, + key, + start, + }); } break; } @@ -387,7 +406,12 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { if (data !== undefined && input.contains(range.startContainer)) { start = getAbsoluteRangeOffset(input, range.startContainer, range.startOffset); - this.#api.text.insert(data, this.blockId, key, start); + this.#api.text.insert({ + text: data, + block: this.blockId, + key, + start, + }); } break; } @@ -443,10 +467,14 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #handleSplit(key: string, start: number, end: number): void { const currentBlockIndex = this.#api.blocks.getIndexById(this.blockId); - const currentValue = this.#api.text.get(this.blockId, key); + const currentValue = this.#api.text.get({ block: this.blockId, + key }); const newValueAfter = currentValue.slice(end); - const relatedFragments = this.#api.text.getFragments(this.blockId, key, end, currentValue.length); + const relatedFragments = this.#api.text.getFragments({ block: this.blockId, + key, + start: end, + end: currentValue.length }); /** * Fragment ranges bounds should be decreased by end index, because end is the index of the first character of the new block @@ -456,21 +484,26 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { fragment.range[1] -= end; }); - this.#api.text.remove(this.blockId, key, start, currentValue.length); - this.#api.blocks.insert( + this.#api.text.remove({ + block: this.blockId, + key, + start, + end: currentValue.length, + }); + this.#api.blocks.insert({ /** * @todo when implementing split/merge, think of how to not use toolname here */ - this.#toolName, - { + type: this.#toolName, + data: { [key]: { $t: 't', value: newValueAfter, fragments: relatedFragments, }, }, - currentBlockIndex + 1 - ); + index: currentBlockIndex + 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/FormattingAdapter/index.ts b/packages/dom-adapters/src/FormattingAdapter/index.ts index 5c70eb81..eb2e91d1 100644 --- a/packages/dom-adapters/src/FormattingAdapter/index.ts +++ b/packages/dom-adapters/src/FormattingAdapter/index.ts @@ -155,7 +155,12 @@ export class FormattingAdapter { const rangeStart = Math.max(0, textRange[0] - 1); const rangeEnd = inputContent !== null ? Math.min(inputContent.length, textRange[1] + 1) : 0; - const affectedFragments = this.#api.text.getFragments(blockIndex, dataKey as string, rangeStart, rangeEnd); + const affectedFragments = this.#api.text.getFragments({ + block: blockIndex, + key: dataKey as string, + start: rangeStart, + end: rangeEnd, + }); const leftBoundary = affectedFragments[0]?.range[0] ?? textRange[0]; let rightBoundary = textRange[1]; diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 4b14b1b1..81d2ef28 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -15,21 +15,28 @@ export interface BlocksAPI { /** * Inserts a new block to the editor * @todo return block api? - * @param type - Block tool name to insert - * @param data - Block's initial data - * @param index - index to insert block at - * @param focus - flag indicates if new block should be focused @todo implement - * @param replace - flag indicates if block at index should be replaced @todo implement - * @param id - id of the inserted block @todo implement - */ - insert( - type?: string, - data?: BlockToolData, - index?: number, - focus?: boolean, - replace?: boolean, - id?: string - ): void; + * @param [params] - optional insert parameters + * @param [params.type] - Block tool name to insert, inserts default block if not specified + * @param [params.data] - Block's initial data + * @param [params.index] - index to insert block at + * @param [params.focus] - flag indicates if new block should be focused @todo implement + * @param [params.replace] - flag indicates if block at index should be replaced @todo implement + * @param [params.id] - id of the inserted block @todo implement + */ + insert(params?: { + /** Block tool name to insert */ + type?: string; + /** Block's initial data */ + data?: BlockToolData; + /** Index to insert block at */ + index?: number; + /** Flag indicates if new block should be focused */ + focus?: boolean; + /** Flag indicates if block at index should be replaced */ + replace?: boolean; + /** Id of the inserted block */ + id?: string; + }): void; /** * Remove all blocks from Document @@ -50,17 +57,27 @@ export interface BlocksAPI { // renderFromHTML(data: string): Promise; /** - * Removes Block by index, or current block if index is not passed - * @param indexOrId - index or id of a block to delete + * Removes Block by index or id, or current block if params are not passed + * @param [params] - optional delete parameters + * @param [params.block] - index or id of a block to delete */ - delete(indexOrId?: number | string): void; + delete(params?: { + /** Index or id of a block to delete */ + block?: number | string; + }): void; /** * Moves a block to a new index - * @param toIndex - index where the block is moved to - * @param [fromIndex] - block to move. Current block if not passed + * @param params - move parameters + * @param params.toIndex - index where the block is moved to + * @param [params.fromIndex] - block to move. Current block if not passed */ - move(toIndex: number, fromIndex?: number): void; + move(params: { + /** Index where the block is moved to */ + toIndex: number; + /** Block to move. Current block if not passed */ + fromIndex?: number; + }): void; /** * Returns Block API object by passed Block index @@ -98,13 +115,16 @@ export interface BlocksAPI { /** * Inserts several Blocks to specified index - * @param blocks - array of blocks to insert - * @param [index] - index to insert blocks at. If undefined, inserts at the end + * @param params - insertMany parameters + * @param params.blocks - array of blocks to insert + * @param [params.index] - index to insert blocks at. If undefined, inserts at the end */ - insertMany( - blocks: BlockNodeInit[], - index?: number, - ): void; // BlockAPI[]; + insertMany(params: { + /** Array of blocks to insert */ + blocks: BlockNodeInit[]; + /** Index to insert blocks at. If undefined, inserts at the end */ + index?: number; + }): void; /** * Returns block's index by its id @@ -120,33 +140,61 @@ export interface BlocksAPI { /** * Returns serialized data for provided data key - * @param indexOrId - index or id of the block - * @param dataKey - data key to get + * @param params - getData parameters + * @param params.block - index or id of the block + * @param params.key - data key to get */ - getData(indexOrId: number | string, dataKey: string): TextNodeSerialized | ValueSerialized | undefined; + getData(params: { + /** Index or id of the block */ + block: number | string; + /** Data key to get */ + key: string; + }): TextNodeSerialized | ValueSerialized | undefined; /** * Removes data by the data key - * @param indexOrId - index or id of the block - * @param dataKey - data key to remove + * @param params - removeData parameters + * @param params.block - index or id of the block + * @param params.key - data key to remove */ - removeData(indexOrId: number | string, dataKey: string): void; + removeData(params: { + /** Index or id of the block */ + block: number | string; + /** Data key to remove */ + key: string; + }): void; /** * Creates data node with the given key - * @param indexOrId - index or id of the block - * @param dataKey - data key to create - * @param [initialData] - optional initial data + * @param params - createData parameters + * @param params.block - index or id of the block + * @param params.key - data key to create + * @param [params.initialData] - optional initial data */ - createData(indexOrId: number | string, dataKey: string, initialData?: TextNodeSerialized | ValueSerialized): void; + createData(params: { + /** Index or id of the block */ + block: number | string; + /** Data key to create */ + key: string; + /** Optional initial data */ + initialData?: TextNodeSerialized | ValueSerialized; + }): void; /** * Updates value by the given key - * @param indexOrId - index or id of the block - * @param dataKey - data key to update - * @param value - new value - */ - updateValue(indexOrId: number | string, dataKey: string, value: V): void; + * @param params - updateValue parameters + * @param params.block - index or id of the block + * @param params.key - data key to update + * @param params.value - new value + */ + updateValue(params: { + /** Index or id of the block */ + block: number | string; + /** Data key to update */ + key: string; + /** New value */ + value: V; + }): void; /** * Creates data of an empty block with a passed type. diff --git a/packages/sdk/src/api/SelectionAPI.ts b/packages/sdk/src/api/SelectionAPI.ts index c7cfe305..6d2bb551 100644 --- a/packages/sdk/src/api/SelectionAPI.ts +++ b/packages/sdk/src/api/SelectionAPI.ts @@ -1,4 +1,4 @@ -import type { Caret, CaretManagerEvents, InlineToolName } from '@editorjs/model'; +import type { Caret, CaretManagerEvents } from '@editorjs/model'; /** * Selection API interface @@ -7,10 +7,12 @@ import type { Caret, CaretManagerEvents, InlineToolName } from '@editorjs/model' export interface SelectionAPI { /** * Applies inline tool for the current selection - * @param tool - name of the inline tool to apply - * @param data - optional data for the inline tool + * @param params - method parameters + * @param params.tool - name of the inline tool to apply + * @param [params.data] - optional data for the inline tool */ - applyInlineToolForCurrentSelection(tool: InlineToolName, data?: Record): void; + // eslint-disable-next-line jsdoc/require-jsdoc,@stylistic/object-property-newline -- type declaration + applyInlineTool({ tool, data }: { tool: string; data?: Record }): void; /** * Registers a callback for CaretManager updates. Returns a cleanup function diff --git a/packages/sdk/src/api/TextAPI.ts b/packages/sdk/src/api/TextAPI.ts index 2d95f9ab..fe3bc6b5 100644 --- a/packages/sdk/src/api/TextAPI.ts +++ b/packages/sdk/src/api/TextAPI.ts @@ -2,95 +2,109 @@ import type { InlineFragment } from '@editorjs/model'; +/** + * Interface representing text position within a block + */ +interface TextPosition { + /** + * Block identifier or index + */ + block: string | number; + /** + * Data key + */ + key: string; + /** + * Text range start + */ + start?: number; + /** + * Text range end + */ + end?: number; +} + +/** + * Interface representing text content + */ +interface TextContent { + /** + * Text content + */ + text: string; +} + +/** + * Interface representing Inline Tool Data + */ +interface InlineToolData { + /** + * Tool name + */ + tool: string; + /** + * Tool data + */ + data?: Record; +} + /** * Editor's TextAPI to work with text content of the document */ export interface TextAPI { /** * Inserts text at a given position - * @param text - new text to insert - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - start offset + * @param params.text - new text to insert + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param [params.start] - start offset */ - insert( - text: string, - blockIndexOrId: number | string, - dataKey: string, - start?: number - ): void; + insert(params: TextContent & Omit): void; /** * Removes text from a given range - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param [params.start] - range start + * @param [params.end] - range end */ - remove( - blockIndexOrId: number | string, - dataKey: string, - start?: number, - end?: number - ): string; + remove(params: TextPosition): string; /** * Formats the given range - * @param tool - tool to apply - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end - * @param data - optional tool's data + * @param params.tool - tool to apply + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end + * @param [params.data] - optional tool's data */ - format( - tool: string, - blockIndexOrId: number | string, - dataKey: string, - start: number, - end: number, - data?: Record, - ): void; + format(params: InlineToolData & Required): void; /** * Unformats the given range - * @param tool - tool to remove - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end + * @param params.tool - tool to remove + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end */ - unformat( - tool: string, - blockIndexOrId: number | string, - dataKey: string, - start: number, - end: number - ): void; + unformat(params: Pick & Required): void; /** * Returns applied inline fragments for a given range - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node - * @param start - range start - * @param end - range end - * @param tool - optional filter tool. If provided, will return only fragments of the given tool + * @param params.block - block index or identifier + * @param params.key - data key of the text node + * @param params.start - range start + * @param params.end - range end + * @param [params.tool] - optional filter tool. If provided, will return only fragments of the given tool */ - getFragments( - blockIndexOrId: number | string, - dataKey: string, - start?: number, - end?: number, - tool?: string - ): InlineFragment[]; + getFragments(params: Partial> & TextPosition): InlineFragment[]; /** * Returns text content of the text node - * @param blockIndexOrId - block index or identifier - * @param dataKey - data key of the text node + * @param params.block - block index or identifier + * @param params.key - data key of the text node */ - get( - blockIndexOrId: number | string, - dataKey: string - ): string; + get(params: Pick): string; } diff --git a/packages/sdk/src/entities/BlockToolAdapter.ts b/packages/sdk/src/entities/BlockToolAdapter.ts index 10059cb4..62534edd 100644 --- a/packages/sdk/src/entities/BlockToolAdapter.ts +++ b/packages/sdk/src/entities/BlockToolAdapter.ts @@ -113,7 +113,11 @@ export abstract class BlockToolAdapter extends EventTarget { this.#createDataNode(key, initialData); return (newValue: V) => { - this.#api.blocks.updateValue(this.blockId, key, newValue); + this.#api.blocks.updateValue({ + block: this.blockId, + key, + value: newValue, + }); }; } @@ -122,11 +126,17 @@ export abstract class BlockToolAdapter extends EventTarget { * @param key - key of the node to remove */ public removeKey(key: string): void { - if (this.#api.blocks.getData(this.blockId, key) === undefined) { + if (this.#api.blocks.getData({ + block: this.blockId, + key, + }) === undefined) { return; } - this.#api.blocks.removeData(this.blockId, key); + this.#api.blocks.removeData({ + block: this.blockId, + key, + }); } /** @@ -141,11 +151,18 @@ export abstract class BlockToolAdapter extends EventTarget { * this.#createDataNode(createDataKey('items[0].content'), { $t: 'v', value: 'Item text' }); */ #createDataNode(key: string, initialData?: TextNodeSerialized | ValueSerialized): void { - if (this.#api.blocks.getData(this.blockId, key) !== undefined) { + if (this.#api.blocks.getData({ + block: this.blockId, + key, + }) !== undefined) { return; } - this.#api.blocks.createData(this.blockId, key, initialData); + this.#api.blocks.createData({ + block: this.blockId, + key, + initialData, + }); } /** diff --git a/packages/ui/src/InlineToolbar/InlineToolbar.ts b/packages/ui/src/InlineToolbar/InlineToolbar.ts index d6fc0070..dcb6d7ec 100644 --- a/packages/ui/src/InlineToolbar/InlineToolbar.ts +++ b/packages/ui/src/InlineToolbar/InlineToolbar.ts @@ -185,7 +185,7 @@ export class InlineToolbarUI implements EditorjsPlugin { }); } else { button.addEventListener('click', () => { - this.#api.selection.applyInlineToolForCurrentSelection(name); + this.#api.selection.applyInlineTool({ tool: name }); }); } @@ -200,7 +200,10 @@ export class InlineToolbarUI implements EditorjsPlugin { */ #renderToolActions(name: InlineToolName, tool: InlineTool): void { const { element } = tool.renderActions?.((data: InlineToolFormatData) => { - this.#api.selection.applyInlineToolForCurrentSelection(name, data); + this.#api.selection.applyInlineTool({ + tool: name, + data, + }); }) ?? { element: null }; if (element === null) { diff --git a/packages/ui/src/Toolbox/Toolbox.ts b/packages/ui/src/Toolbox/Toolbox.ts index 9c3b25fc..d3df296a 100644 --- a/packages/ui/src/Toolbox/Toolbox.ts +++ b/packages/ui/src/Toolbox/Toolbox.ts @@ -144,12 +144,12 @@ export class ToolboxUI implements EditorjsPlugin { ...toolbox, closeOnActivate: true, onActivate: () => { - void this.#api.blocks.insert( - tool.name, - toolbox.data ?? {}, - this.#selectedBlockIndex === -1 ? undefined : this.#selectedBlockIndex + 1, - true - ); + void this.#api.blocks.insert({ + type: tool.name, + data: toolbox.data ?? {}, + index: this.#selectedBlockIndex === -1 ? undefined : this.#selectedBlockIndex + 1, + focus: true, + }); }, } ); From 55feadef105475a4854b1d26e018b6121b9179d5 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 19 May 2026 00:51:30 +0100 Subject: [PATCH 05/10] Support user id in API methods --- packages/core/src/api/BlocksAPI.ts | 34 ++++++++++++-------- packages/core/src/api/TextAPI.ts | 20 +++++++----- packages/core/src/components/BlockManager.ts | 27 ++++++++++------ packages/sdk/src/api/BlocksAPI.ts | 20 ++++++++++++ packages/sdk/src/api/TextAPI.ts | 22 ++++++++++--- 5 files changed, 89 insertions(+), 34 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 33511cc2..eeb8ac34 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -71,9 +71,10 @@ export class BlocksAPI implements BlocksApiInterface { * Removes Block by index or id, or current block if params are not passed * @param params - delete parameters * @param params.block - index or id of a block to delete + * @param params.userId - user id to attribute the change to */ - public delete({ block }: NonNullable[0]> = {}): void { - return this.#blocksManager.deleteBlock(block); + public delete({ block, userId = this.#config.userId }: NonNullable[0]> = {}): void { + return this.#blocksManager.deleteBlock(block, userId); } /** @@ -81,9 +82,10 @@ export class BlocksAPI implements BlocksApiInterface { * @param params - move parameters * @param params.toIndex - index where the block is moved to * @param [params.fromIndex] - block to move. Current block if not passed + * @param [params.userId] - user id to attribute the change to */ - public move({ toIndex, fromIndex }: Parameters[0]): void { - return this.#blocksManager.move(toIndex, fromIndex); + public move({ toIndex, fromIndex, userId = this.#config.userId }: Parameters[0]): void { + return this.#blocksManager.move(toIndex, fromIndex, userId); } /** @@ -98,9 +100,10 @@ export class BlocksAPI implements BlocksApiInterface { * @param params - insertMany parameters * @param params.blocks - array of blocks to insert * @param [params.index] - index to insert blocks at. If undefined, inserts at the end + * @param [params.userId] - user id to attribute the change to */ - public insertMany({ blocks, index }: Parameters[0]): void { - return this.#blocksManager.insertMany(blocks, index); + public insertMany({ blocks, index, userId = this.#config.userId }: Parameters[0]): void { + return this.#blocksManager.insertMany(blocks, index, userId); } /** @@ -111,8 +114,9 @@ export class BlocksAPI implements BlocksApiInterface { * @param [params.index] - index to insert block at * @param [params.focus] - flag indicates if new block should be focused @todo implement * @param [params.replace] - flag indicates if block at index should be replaced @todo implement + * @param [params.userId] - user id to attribute the change to */ - public insert({ type, data, index, focus, replace }: NonNullable[0]> = {}): void { + public insert({ type, data, index, focus, replace, userId = this.#config.userId }: NonNullable[0]> = {}): void { const blockType = type ?? this.#config.defaultBlock; const blockData = data ?? {}; @@ -122,6 +126,7 @@ export class BlocksAPI implements BlocksApiInterface { index, replace, focus, + userId, }); }; @@ -157,10 +162,11 @@ export class BlocksAPI implements BlocksApiInterface { * @param params.block - index or id of the block * @param params.key - data key of the new data node * @param [params.initialData] - optional initial data + * @param [params.userId] - user id to attribute the change to */ - public createData({ block, key, initialData }: Parameters[0]): void { + public createData({ block, key, initialData, userId = this.#config.userId }: Parameters[0]): void { this.#model.createDataNode( - this.#config.userId, + userId, block as BlockIndexOrId, key, initialData @@ -172,9 +178,10 @@ export class BlocksAPI implements BlocksApiInterface { * @param params - removeData parameters * @param params.block - index or identifier of the block * @param params.key - data key of the node to remove + * @param [params.userId] - user id to attribute the change to */ - public removeData({ block, key }: Parameters[0]): void { - this.#model.removeDataNode(this.#config.userId, block as BlockIndexOrId, key); + public removeData({ block, key, userId = this.#config.userId }: Parameters[0]): void { + this.#model.removeDataNode(userId, block as BlockIndexOrId, key); } /** @@ -183,8 +190,9 @@ export class BlocksAPI implements BlocksApiInterface { * @param params.block - index or identifier of the block * @param params.key - key of the data node to update * @param params.value - new value + * @param [params.userId] - user id to attribute the change to */ - public updateValue({ block, key, value }: Parameters[0]): void { - this.#model.updateValue(this.#config.userId, block as BlockIndexOrId, createDataKey(key), value); + public updateValue({ block, key, value, userId = this.#config.userId }: Parameters[0]): void { + this.#model.updateValue(userId, block as BlockIndexOrId, createDataKey(key), value); } } diff --git a/packages/core/src/api/TextAPI.ts b/packages/core/src/api/TextAPI.ts index 1e51d74e..c4214556 100644 --- a/packages/core/src/api/TextAPI.ts +++ b/packages/core/src/api/TextAPI.ts @@ -45,10 +45,11 @@ export class TextAPI implements TextAPIInterface { * @param params.block - block index or identifier * @param params.key - data key of the text node * @param params.start - start offset + * @param [params.userId] - user id to attribute the change to */ - public insert({ text, block, key, start }: Parameters[0]): void { + public insert({ text, block, key, start, userId = this.#config.userId }: Parameters[0]): void { this.#model.insertText( - this.#config.userId, + userId, block as BlockIndexOrId, createDataKey(key), text, @@ -63,10 +64,11 @@ export class TextAPI implements TextAPIInterface { * @param params.key - data key of the text node * @param params.start - range start * @param params.end - range end + * @param [params.userId] - user id to attribute the change to */ - public remove({ block, key, start, end }: Parameters[0]): string { + public remove({ block, key, start, end, userId = this.#config.userId }: Parameters[0]): string { return this.#model.removeText( - this.#config.userId, + userId, block as BlockIndexOrId, createDataKey(key), start, @@ -83,10 +85,11 @@ export class TextAPI implements TextAPIInterface { * @param params.start - range start * @param params.end - range end * @param params.data - optional tool's data + * @param [params.userId] - user id to attribute the change to */ - public format({ tool, block, key, start, end, data }: Parameters[0]): void { + public format({ tool, block, key, start, end, data, userId = this.#config.userId }: Parameters[0]): void { this.#model.format( - this.#config.userId, + userId, block as BlockIndexOrId, createDataKey(key), createInlineToolName(tool), @@ -104,10 +107,11 @@ export class TextAPI implements TextAPIInterface { * @param params.key - data key of the text node * @param params.start - range start * @param params.end - range end + * @param [params.userId] - user id to attribute the change to */ - public unformat({ tool, block, key, start, end }: Parameters[0]): void { + public unformat({ tool, block, key, start, end, userId = this.#config.userId }: Parameters[0]): void { this.#model.unformat( - this.#config.userId, + userId, block as BlockIndexOrId, createDataKey(key), createInlineToolName(tool), diff --git a/packages/core/src/components/BlockManager.ts b/packages/core/src/components/BlockManager.ts index 710a40d8..21583791 100644 --- a/packages/core/src/components/BlockManager.ts +++ b/packages/core/src/components/BlockManager.ts @@ -45,6 +45,10 @@ interface InsertBlockParameters { */ focus?: boolean; // tunes?: {[name: string]: BlockTuneData}; + /** + * User id to attribute the change to + */ + userId?: string | number; } /** @@ -113,6 +117,7 @@ export class BlocksManager { * @param parameters.index - index to insert block at // * @param parameters.needToFocus - flag indicates if caret should be set to block after insert * @param parameters.replace - flag indicates if block at index should be replaced + * @param parameters.userId - user id to attribute the change to */ public insert({ id = undefined, @@ -121,6 +126,7 @@ export class BlocksManager { index, focus = false, replace = false, + userId = this.#config.userId, // tunes = {}, }: InsertBlockParameters = {}): void { let newIndex = index; @@ -130,10 +136,10 @@ export class BlocksManager { } if (replace) { - this.#model.removeBlock(this.#config.userId, newIndex); + this.#model.removeBlock(userId, newIndex); } - this.#model.addBlock(this.#config.userId, { + this.#model.addBlock(userId, { ...data, id, name: type, @@ -150,9 +156,10 @@ export class BlocksManager { * Inserts several Blocks to specified index * @param blocks - array of blocks to insert * @param [index] - index to insert blocks at. If undefined, inserts at the end + * @param [userId] - user id to attribute the change to */ - public insertMany(blocks: BlockNodeInit[], index: number = this.#model.length): void { - blocks.forEach((block, i) => this.#model.addBlock(this.#config.userId, block, index + i)); + public insertMany(blocks: BlockNodeInit[], index: number = this.#model.length, userId: string | number = this.#config.userId): void { + blocks.forEach((block, i) => this.#model.addBlock(userId, block, index + i)); } /** @@ -173,8 +180,9 @@ export class BlocksManager { /** * Removes Block by index, or current block if index is not passed * @param indexOrId - index or identifier of a block to delete + * @param [userId] - user id to attribute the change to */ - public deleteBlock(indexOrId: number | string | undefined = this.#getCurrentBlockIndex()): void { + public deleteBlock(indexOrId: number | string | undefined = this.#getCurrentBlockIndex(), userId: string | number = this.#config.userId): void { if (indexOrId === undefined) { /** * @todo see what happens in legacy @@ -182,15 +190,16 @@ export class BlocksManager { throw new Error('No block selected to delete'); } - this.#model.removeBlock(this.#config.userId, indexOrId as BlockIndexOrId); + this.#model.removeBlock(userId, indexOrId as BlockIndexOrId); } /** * Moves a block to a new index * @param toIndex - index where the block is moved to * @param [fromIndex] - block to move. Current block if not passed + * @param [userId] - user id to attribute the change to */ - public move(toIndex: number, fromIndex: number | undefined = this.#getCurrentBlockIndex()): void { + public move(toIndex: number, fromIndex: number | undefined = this.#getCurrentBlockIndex(), userId: string | number = this.#config.userId): void { if (fromIndex === undefined) { throw new Error('No block selected to move'); } @@ -204,8 +213,8 @@ export class BlocksManager { const block = this.#model.getBlockSerialized(fromIndex); - this.#model.removeBlock(this.#config.userId, fromIndex); - this.#model.addBlock(this.#config.userId, block, toIndex); + this.#model.removeBlock(userId, fromIndex); + this.#model.addBlock(userId, block, toIndex); } /** diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 81d2ef28..09bf8457 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -36,6 +36,8 @@ export interface BlocksAPI { replace?: boolean; /** Id of the inserted block */ id?: string; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** @@ -60,10 +62,13 @@ export interface BlocksAPI { * Removes Block by index or id, or current block if params are not passed * @param [params] - optional delete parameters * @param [params.block] - index or id of a block to delete + * @param [params.userId] - user id. Defaults to the current user id from the config */ delete(params?: { /** Index or id of a block to delete */ block?: number | string; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** @@ -71,12 +76,15 @@ export interface BlocksAPI { * @param params - move parameters * @param params.toIndex - index where the block is moved to * @param [params.fromIndex] - block to move. Current block if not passed + * @param [params.userId] - user id. Defaults to the current user id from the config */ move(params: { /** Index where the block is moved to */ toIndex: number; /** Block to move. Current block if not passed */ fromIndex?: number; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** @@ -118,12 +126,15 @@ export interface BlocksAPI { * @param params - insertMany parameters * @param params.blocks - array of blocks to insert * @param [params.index] - index to insert blocks at. If undefined, inserts at the end + * @param [params.userId] - user id. Defaults to the current user id from the config */ insertMany(params: { /** Array of blocks to insert */ blocks: BlockNodeInit[]; /** Index to insert blocks at. If undefined, inserts at the end */ index?: number; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** @@ -156,12 +167,15 @@ export interface BlocksAPI { * @param params - removeData parameters * @param params.block - index or id of the block * @param params.key - data key to remove + * @param [params.userId] - user id. Defaults to the current user id from the config */ removeData(params: { /** Index or id of the block */ block: number | string; /** Data key to remove */ key: string; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** @@ -170,6 +184,7 @@ export interface BlocksAPI { * @param params.block - index or id of the block * @param params.key - data key to create * @param [params.initialData] - optional initial data + * @param [params.userId] - user id. Defaults to the current user id from the config */ createData(params: { /** Index or id of the block */ @@ -178,6 +193,8 @@ export interface BlocksAPI { key: string; /** Optional initial data */ initialData?: TextNodeSerialized | ValueSerialized; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** @@ -186,6 +203,7 @@ export interface BlocksAPI { * @param params.block - index or id of the block * @param params.key - data key to update * @param params.value - new value + * @param [params.userId] - user id. Defaults to the current user id from the config */ updateValue(params: { /** Index or id of the block */ @@ -194,6 +212,8 @@ export interface BlocksAPI { key: string; /** New value */ value: V; + /** User id. Defaults to the current user id from the config */ + userId?: string | number; }): void; /** diff --git a/packages/sdk/src/api/TextAPI.ts b/packages/sdk/src/api/TextAPI.ts index fe3bc6b5..bf8e4861 100644 --- a/packages/sdk/src/api/TextAPI.ts +++ b/packages/sdk/src/api/TextAPI.ts @@ -48,6 +48,16 @@ interface InlineToolData { data?: Record; } +/** + * Optional user id parameter for mutating operations + */ +interface UserIdParam { + /** + * User id. Defaults to the current user id from the config + */ + userId?: string | number; +} + /** * Editor's TextAPI to work with text content of the document */ @@ -58,8 +68,9 @@ export interface TextAPI { * @param params.block - block index or identifier * @param params.key - data key of the text node * @param [params.start] - start offset + * @param [params.userId] - user id. Defaults to the current user id from the config */ - insert(params: TextContent & Omit): void; + insert(params: TextContent & Omit & UserIdParam): void; /** * Removes text from a given range @@ -67,8 +78,9 @@ export interface TextAPI { * @param params.key - data key of the text node * @param [params.start] - range start * @param [params.end] - range end + * @param [params.userId] - user id. Defaults to the current user id from the config */ - remove(params: TextPosition): string; + remove(params: TextPosition & UserIdParam): string; /** * Formats the given range @@ -78,8 +90,9 @@ export interface TextAPI { * @param params.start - range start * @param params.end - range end * @param [params.data] - optional tool's data + * @param [params.userId] - user id. Defaults to the current user id from the config */ - format(params: InlineToolData & Required): void; + format(params: InlineToolData & Required & UserIdParam): void; /** * Unformats the given range @@ -88,8 +101,9 @@ export interface TextAPI { * @param params.key - data key of the text node * @param params.start - range start * @param params.end - range end + * @param [params.userId] - user id. Defaults to the current user id from the config */ - unformat(params: Pick & Required): void; + unformat(params: Pick & Required & UserIdParam): void; /** * Returns applied inline fragments for a given range From a5d2c360e41c4a187a9c61db7ee532ff3e1ef8e3 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 19 May 2026 18:19:35 +0100 Subject: [PATCH 06/10] Fix core tests --- packages/core/src/api/BlocksAPI.spec.ts | 41 +++++++++++++++++-------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index e0b56e8a..42499fad 100644 --- a/packages/core/src/api/BlocksAPI.spec.ts +++ b/packages/core/src/api/BlocksAPI.spec.ts @@ -68,25 +68,31 @@ describe('BlocksAPI', () => { it('should pass explicit index to blocksManager.deleteBlock', () => { const api = new BlocksAPI( blocksManager, - { defaultBlock } as CoreConfigValidated, + { + defaultBlock, + userId: 'userId', + } as CoreConfigValidated, new EditorJSModel('userId', { identifier: 'docId' }) ); api.delete({ block: 2 }); - expect(blocksManager.deleteBlock).toHaveBeenCalledWith(2); + expect(blocksManager.deleteBlock).toHaveBeenCalledWith(2, 'userId'); }); it('should pass undefined when index is omitted', () => { const api = new BlocksAPI( blocksManager, - { defaultBlock } as CoreConfigValidated, + { + defaultBlock, + userId: 'userId', + } as CoreConfigValidated, new EditorJSModel('userId', { identifier: 'docId' }) ); api.delete(); - expect(blocksManager.deleteBlock).toHaveBeenCalledWith(undefined); + expect(blocksManager.deleteBlock).toHaveBeenCalledWith(undefined, 'userId'); }); }); @@ -94,14 +100,19 @@ describe('BlocksAPI', () => { it('should call blocksManager.move with toIndex and fromIndex', () => { const api = new BlocksAPI( blocksManager, - { defaultBlock } as CoreConfigValidated, + { + defaultBlock, + userId: 'userId', + } as CoreConfigValidated, new EditorJSModel('userId', { identifier: 'docId' }) ); - api.move({ toIndex: 3, - fromIndex: 1 }); + api.move({ + toIndex: 3, + fromIndex: 1, + }); - expect(blocksManager.move).toHaveBeenCalledWith(3, 1); + expect(blocksManager.move).toHaveBeenCalledWith(3, 1, 'userId'); }); }); @@ -124,7 +135,10 @@ describe('BlocksAPI', () => { it('should pass blocks and index to blocksManager.insertMany', () => { const api = new BlocksAPI( blocksManager, - { defaultBlock } as CoreConfigValidated, + { + defaultBlock, + userId: 'userId', + } as CoreConfigValidated, new EditorJSModel('userId', { identifier: 'docId' }) ); @@ -138,13 +152,16 @@ describe('BlocksAPI', () => { index: 4, }); - expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, 4); + expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, 4, 'userId'); }); it('should pass undefined index to blocksManager.insertMany when omitted', () => { const api = new BlocksAPI( blocksManager, - { defaultBlock } as CoreConfigValidated, + { + defaultBlock, + userId: 'userId', + } as CoreConfigValidated, new EditorJSModel('userId', { identifier: 'docId' }) ); @@ -155,7 +172,7 @@ describe('BlocksAPI', () => { api.insertMany({ blocks }); - expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, undefined); + expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, undefined, 'userId'); }); }); From bee01d9921dde74a3cbd181c3ba5e0b5cf9e2435 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 19 May 2026 18:31:35 +0100 Subject: [PATCH 07/10] Make CollaborationManager a Plugin --- packages/collaboration-manager/jest.config.ts | 5 +- .../src/CollaborationManager.spec.ts | 66 ++--- .../src/CollaborationManager.ts | 139 ++++++---- .../src/client/OTClient.spec.ts | 260 ++++++++++++------ .../test/mocks/codex-tooltip.ts | 13 + .../test/mocks/createManager.ts | 64 +++++ .../collaboration-manager/test/mocks/ws.ts | 20 +- .../src/api/DocumentAPI/DocumentAPI.spec.ts | 3 +- .../core/src/api/DocumentAPI/DocumentAPI.ts | 57 +++- packages/core/src/index.ts | 26 +- packages/ot-server/jest.config.ts | 1 + .../ot-server/test/mocks/codex-tooltip.ts | 13 + packages/sdk/src/api/DocumentAPI.ts | 44 ++- .../EventBus/events/core/CoreEventType.ts | 7 +- 14 files changed, 521 insertions(+), 197 deletions(-) create mode 100644 packages/collaboration-manager/test/mocks/codex-tooltip.ts create mode 100644 packages/collaboration-manager/test/mocks/createManager.ts create mode 100644 packages/ot-server/test/mocks/codex-tooltip.ts 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..31bd04c6 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -4,9 +4,9 @@ import { EditorJSModel } from '@editorjs/model'; import 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'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -31,7 +31,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 +51,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 +92,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 +125,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 +173,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 +202,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 +249,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 +279,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 +329,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 +382,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 +424,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 +467,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 +510,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 +546,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 +586,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 +636,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 +689,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 +742,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 +778,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 +817,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 +859,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 +900,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 +952,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 +1005,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 +1045,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 +1076,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 +1135,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 +1176,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 +1244,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 +1305,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) diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index 7dac0f42..a96d3df0 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 { + /** + * Plugin type + */ + public static readonly type = PluginType.Plugin; + /** - * EditorJSModel instance to listen to and apply operations + * Editor API instance used to interact with the document */ - #model: EditorJSModel; + #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,34 @@ 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 + * Editor's config */ - constructor(config: Required, model: EditorJSModel) { - this.#config = config; - this.#model = model; - this.#undoRedoManager = new UndoRedoManager(); - model.addEventListener(EventType.Changed, this.#handleEvent.bind(this)); - } + #config: EditorjsPluginParams['config']; /** - * Connects to OT server + * Creates an instance of CollaborationManager plugin + * @param params - plugin constructor parameters */ - public connect(): void { - if (this.#config.collaborationServer === undefined) { - return; - } + constructor(params: EditorjsPluginParams) { + const { api, config, eventBus } = params; - this.#client = new OTClient( - this.#config.collaborationServer, - this.#config.userId, - (data) => { - if (!data) { - return; - } + this.#api = api; + this.#config = config; + this.#undoRedoManager = new UndoRedoManager(); - this.#model.initializeDocument(data); - }, - (op) => { - this.applyOperation(op); - } - ); + api.document.onUpdate(this.#handleEvent.bind(this)); + + eventBus.addEventListener(`core:${CoreEventType.Undo}`, () => { + this.undo(); + }); - void this.#client.connectDocument(this.#model.serialized); + eventBus.addEventListener(`core:${CoreEventType.Redo}`, () => { + this.redo(); + }); + + eventBus.addEventListener(`core:${CoreEventType.Ready}`, () => { + this.#connect(); + }); } /** @@ -137,7 +135,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 +154,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 +183,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 +281,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 +328,11 @@ export class CollaborationManager { this.#putBatchToUndo(); }, DEBOUNCE_TIMEOUT); } + + /** + * Destroys the plugin instance: clears the debounce timer + */ + public destroy(): void { + clearTimeout(this.#debounceTimer); + } } diff --git a/packages/collaboration-manager/src/client/OTClient.spec.ts b/packages/collaboration-manager/src/client/OTClient.spec.ts index 10c9ea77..2855da34 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,117 @@ 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); - applySpy.mockRestore(); + // 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 + ); - jest.useFakeTimers(); + expect(opCalls).toHaveLength(1); + sendSpy.mockRestore(); }); }); }); 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..6f71ff35 --- /dev/null +++ b/packages/collaboration-manager/test/mocks/createManager.ts @@ -0,0 +1,64 @@ +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: {} 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..1e6652b4 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; @@ -95,7 +99,6 @@ export class MockWebSocket { /** * Deliver a server → client WebSocket payload (remote operation, etc.). - * * @param payload - payload to receive from the server */ public receiveFromServer(payload: unknown): void { @@ -104,12 +107,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..6af17b80 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'; /** * 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' } From 83e4bb043bf8157f8737c5a04fc76f94d8f71acf Mon Sep 17 00:00:00 2001 From: George Berezhnoy Date: Tue, 19 May 2026 18:38:10 +0100 Subject: [PATCH 08/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/core/src/api/DocumentAPI/DocumentAPI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/api/DocumentAPI/DocumentAPI.ts b/packages/core/src/api/DocumentAPI/DocumentAPI.ts index 6af17b80..777559c8 100644 --- a/packages/core/src/api/DocumentAPI/DocumentAPI.ts +++ b/packages/core/src/api/DocumentAPI/DocumentAPI.ts @@ -8,7 +8,7 @@ import { type ModifyDataParams } from '@editorjs/sdk'; import { inject, injectable } from 'inversify'; -import { TOKENS } from '../../tokens'; +import { TOKENS } from '../../tokens.js'; /** * Document API From 6c1027481c02fc8c8630f0c4ffd8338cab9018a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 19 May 2026 17:44:19 +0000 Subject: [PATCH 09/10] Fix CollaborationManager destroy cleanup and OT client close handling Agent-Logs-Url: https://github.com/editor-js/document-model/sessions/48043d73-bfdc-4210-8f53-4d2128ebbfc0 Co-authored-by: gohabereg <23050529+gohabereg@users.noreply.github.com> --- .../src/CollaborationManager.spec.ts | 48 ++++++++++++++++- .../src/CollaborationManager.ts | 53 +++++++++++++++---- .../src/client/OTClient.spec.ts | 16 ++++++ .../src/client/OTClient.ts | 9 ++++ .../test/mocks/createManager.ts | 4 +- .../collaboration-manager/test/mocks/ws.ts | 7 +++ 6 files changed, 124 insertions(+), 13 deletions(-) diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 31bd04c6..1223d2ff 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 { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; import { createManager } from '../test/mocks/createManager.js'; +import { MockWebSocket } from '../test/mocks/ws.js'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -1342,4 +1343,47 @@ 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 originalWebSocket = globalThis.WebSocket; + + try { + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; + + 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(MockWebSocket.lastInstance!, 'close'); + + manager.destroy(); + await Promise.resolve(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); + } finally { + globalThis.WebSocket = originalWebSocket; + } + }); + }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index a96d3df0..c6f8f58a 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -61,6 +61,26 @@ export class CollaborationManager implements EditorjsPlugin { */ #debounceTimer?: ReturnType; + /** + * Cleanup callback for document updates listener + */ + #unsubscribeDocumentUpdates?: () => void; + + /** + * Cleanup callback for undo event listener + */ + #unsubscribeUndo?: () => void; + + /** + * Cleanup callback for redo event listener + */ + #unsubscribeRedo?: () => void; + + /** + * Cleanup callback for ready event listener + */ + #unsubscribeReady?: () => void; + /** * Editor's config */ @@ -77,19 +97,26 @@ export class CollaborationManager implements EditorjsPlugin { this.#config = config; this.#undoRedoManager = new UndoRedoManager(); - api.document.onUpdate(this.#handleEvent.bind(this)); - - eventBus.addEventListener(`core:${CoreEventType.Undo}`, () => { + const onUndo = (): void => { this.undo(); - }); - - eventBus.addEventListener(`core:${CoreEventType.Redo}`, () => { + }; + const onRedo = (): void => { this.redo(); - }); - - eventBus.addEventListener(`core:${CoreEventType.Ready}`, () => { + }; + const onReady = (): void => { this.#connect(); - }); + }; + + this.#unsubscribeDocumentUpdates = api.document.onUpdate(this.#handleEvent.bind(this)); + + eventBus.addEventListener(`core:${CoreEventType.Undo}`, onUndo); + this.#unsubscribeUndo = () => eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); + + eventBus.addEventListener(`core:${CoreEventType.Redo}`, onRedo); + this.#unsubscribeRedo = () => eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); + + eventBus.addEventListener(`core:${CoreEventType.Ready}`, onReady); + this.#unsubscribeReady = () => eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); } /** @@ -334,5 +361,11 @@ export class CollaborationManager implements EditorjsPlugin { */ 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 2855da34..0bce6234 100644 --- a/packages/collaboration-manager/src/client/OTClient.spec.ts +++ b/packages/collaboration-manager/src/client/OTClient.spec.ts @@ -229,4 +229,20 @@ describe('OTClient', () => { 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'); + + client.close(); + await Promise.resolve(); + + 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/createManager.ts b/packages/collaboration-manager/test/mocks/createManager.ts index 6f71ff35..b018faf1 100644 --- a/packages/collaboration-manager/test/mocks/createManager.ts +++ b/packages/collaboration-manager/test/mocks/createManager.ts @@ -44,7 +44,9 @@ export function createManager(config: CoreConfigValidated, model: EditorJSModel) const api: EditorAPI = { document: createMockDocumentAPI(model), // eslint-disable-next-line @typescript-eslint/no-explicit-any - blocks: {} as 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 diff --git a/packages/collaboration-manager/test/mocks/ws.ts b/packages/collaboration-manager/test/mocks/ws.ts index 1e6652b4..40e2d38c 100644 --- a/packages/collaboration-manager/test/mocks/ws.ts +++ b/packages/collaboration-manager/test/mocks/ws.ts @@ -97,6 +97,13 @@ 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 From 11fbc61ff1359ac694c9c8c08f740f5a4805e497 Mon Sep 17 00:00:00 2001 From: gohabereg Date: Tue, 19 May 2026 23:55:40 +0100 Subject: [PATCH 10/10] Fix CI checks --- .../src/CollaborationManager.spec.ts | 34 +++++++------------ .../src/CollaborationManager.ts | 6 ++-- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/packages/collaboration-manager/src/CollaborationManager.spec.ts b/packages/collaboration-manager/src/CollaborationManager.spec.ts index 1223d2ff..61480e72 100644 --- a/packages/collaboration-manager/src/CollaborationManager.spec.ts +++ b/packages/collaboration-manager/src/CollaborationManager.spec.ts @@ -7,7 +7,7 @@ import { BatchedOperation } from './BatchedOperation.js'; import { Operation, OperationType } from './Operation.js'; import { UndoRedoManager } from './UndoRedoManager.js'; import { createManager } from '../test/mocks/createManager.js'; -import { MockWebSocket } from '../test/mocks/ws.js'; +import { OTClient } from './client/index.js'; const userId = 'user'; const remoteUserId = 'remote-user'; @@ -1360,30 +1360,22 @@ describe('CollaborationManager', () => { }); it('should close ot client connection', async () => { - const originalWebSocket = globalThis.WebSocket; - - try { - globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; - - const model = new EditorJSModel(userId, { identifier: documentId }); - const { manager, eventBus } = createManager({ - ...config, - collaborationServer: 'ws://test-collab.invalid/document', - } as Required, model); + 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(); + eventBus.dispatchEvent(new CustomEvent(`core:${CoreEventType.Ready}`)); + await Promise.resolve(); - const closeSpy = jest.spyOn(MockWebSocket.lastInstance!, 'close'); + const closeSpy = jest.spyOn(OTClient.prototype, 'close'); - manager.destroy(); - await Promise.resolve(); + manager.destroy(); + await Promise.resolve(); - expect(closeSpy).toHaveBeenCalledTimes(1); - closeSpy.mockRestore(); - } finally { - globalThis.WebSocket = originalWebSocket; - } + expect(closeSpy).toHaveBeenCalledTimes(1); + closeSpy.mockRestore(); }); }); }); diff --git a/packages/collaboration-manager/src/CollaborationManager.ts b/packages/collaboration-manager/src/CollaborationManager.ts index c6f8f58a..acbfbf48 100644 --- a/packages/collaboration-manager/src/CollaborationManager.ts +++ b/packages/collaboration-manager/src/CollaborationManager.ts @@ -110,13 +110,13 @@ export class CollaborationManager implements EditorjsPlugin { this.#unsubscribeDocumentUpdates = api.document.onUpdate(this.#handleEvent.bind(this)); eventBus.addEventListener(`core:${CoreEventType.Undo}`, onUndo); - this.#unsubscribeUndo = () => eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); + this.#unsubscribeUndo = () => void eventBus.removeEventListener(`core:${CoreEventType.Undo}`, onUndo); eventBus.addEventListener(`core:${CoreEventType.Redo}`, onRedo); - this.#unsubscribeRedo = () => eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); + this.#unsubscribeRedo = () => void eventBus.removeEventListener(`core:${CoreEventType.Redo}`, onRedo); eventBus.addEventListener(`core:${CoreEventType.Ready}`, onReady); - this.#unsubscribeReady = () => eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); + this.#unsubscribeReady = () => void eventBus.removeEventListener(`core:${CoreEventType.Ready}`, onReady); } /**