diff --git a/packages/core/src/api/BlocksAPI.integration.spec.ts b/packages/core/src/api/BlocksAPI.integration.spec.ts index 82619fb6..b6435369 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(() => { @@ -91,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( @@ -100,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( @@ -121,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( @@ -135,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: {}, @@ -148,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' })); @@ -158,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' })); @@ -179,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' })); @@ -191,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'); }); @@ -209,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'); }); }); @@ -251,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); }); @@ -265,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', @@ -285,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', @@ -299,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(); @@ -322,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(); @@ -330,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 @@ -348,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 @@ -372,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({ @@ -394,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 cc502c1d..e0b56e8a 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,15 +66,23 @@ 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); + api.delete({ block: 2 }); expect(blocksManager.deleteBlock).toHaveBeenCalledWith(2); }); 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,9 +92,14 @@ 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); + api.move({ toIndex: 3, + fromIndex: 1 }); expect(blocksManager.move).toHaveBeenCalledWith(3, 1); }); @@ -77,7 +107,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,23 +122,38 @@ 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); + api.insertMany({ + blocks: blocks, + index: 4, + }); expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, 4); }); 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); + api.insertMany({ blocks }); expect(blocksManager.insertMany).toHaveBeenCalledWith(blocks, undefined); }); @@ -112,7 +161,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,17 +178,21 @@ describe('BlocksAPI', () => { }); it('should pass provided params to blocksManager.insert and ignore compatibility args', () => { - const api = new BlocksAPI(blocksManager, { defaultBlock } as CoreConfigValidated); - - api.insert( - 'header', - { text: 'Title' }, - 2, - true, - true, - 'id-1' + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + new EditorJSModel('userId', { identifier: 'docId' }) ); + api.insert({ + type: 'header', + data: { text: 'Title' }, + index: 2, + focus: true, + replace: true, + id: 'id-1', + }); + expect(blocksManager.insert).toHaveBeenCalledWith({ type: 'header', data: { text: 'Title' }, diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index a29055e1..33511cc2 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -2,10 +2,18 @@ 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 { type BlockNodeInit, type EditorDocumentSerialized } from '@editorjs/model'; +import { + BlockId, + BlockIndexOrId, + createBlockId, + createDataKey, + EditorDocumentSerialized, + EditorJSModel, + TextNodeSerialized, + ValueSerialized +} from '@editorjs/model'; /** * Blocks API @@ -23,17 +31,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; } /** @@ -52,19 +68,21 @@ 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 + * 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(index?: number): void { - return this.#blocksManager.deleteBlock(index); + 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); } @@ -77,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 ?? {}; @@ -113,4 +124,67 @@ 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 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 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, + block as BlockIndexOrId, + key, + initialData + ); + } + + /** + * Removes data by the data key + * @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({ block, key }: Parameters[0]): void { + this.#model.removeDataNode(this.#config.userId, block as BlockIndexOrId, key); + } + + /** + * Updates value by the given key + * @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({ 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/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..e01f09ab 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,22 +10,33 @@ 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 const selectionManager = new SelectionManager(); - describe('.applyInlineToolForCurrentSelection()', () => { + describe('.applyInlineTool()', () => { 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.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 4e139b26..7c968e40 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 } from '@editorjs/sdk'; import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk'; +import { TOKENS } from '../tokens.js'; /** * Selection API class @@ -13,23 +14,60 @@ 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 - * @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 applyInlineTool({ tool, data }: Parameters[0]): void { + this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(tool), 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 applyInlineToolForCurrentSelection(toolName: string, data?: InlineToolFormatData): void { - this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(toolName), data); + 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..3948fa66 --- /dev/null +++ b/packages/core/src/api/TextAPI.ts @@ -0,0 +1,150 @@ +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.js'; +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 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, block, key, start }: Parameters[0]): void { + this.#model.insertText( + this.#config.userId, + block as BlockIndexOrId, + createDataKey(key), + text, + start + ); + } + + /** + * Removes text from a given range. Returns removed text + * @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({ block, key, start, end }: Parameters[0]): string { + return this.#model.removeText( + this.#config.userId, + block as BlockIndexOrId, + createDataKey(key), + start, + end + ); + } + + /** + * Formats the given range + * @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, block, key, start, end, data }: Parameters[0]): void { + this.#model.format( + this.#config.userId, + block as BlockIndexOrId, + createDataKey(key), + createInlineToolName(tool), + start, + end, + data !== undefined ? createInlineToolData(data) : undefined + ); + } + + /** + * Unformats the given range + * @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, block, key, start, end }: Parameters[0]): void { + this.#model.unformat( + this.#config.userId, + block as BlockIndexOrId, + createDataKey(key), + createInlineToolName(tool), + start, + end + ); + } + + /** + * Returns applied inline fragments for a given range + * @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({ block, key, start, end, tool }: Parameters[0]): InlineFragment[] { + return this.#model.getFragments( + block as BlockIndexOrId, + createDataKey(key), + start, + end, + tool !== undefined ? createInlineToolName(tool) : undefined + ); + } + + /** + * Returns text content 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({ block, key }: Parameters[0]): string { + return this.#model.getText( + block as BlockIndexOrId, + createDataKey(key) + ); + } +} 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/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); } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35f39c36..a77562bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -245,7 +245,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/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 070b0814..2c29efa7 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,34 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #beforeInputListener: EventListener; + /** + * Editor API instance + */ + #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 +113,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 +137,14 @@ 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({ + block: this.blockId, + key, + }); + const fragments = this.#api.text.getFragments({ + block: this.blockId, + key, + }); this.#inputsRegistry.register(this.blockId, key, input); @@ -150,8 +158,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 +167,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 +176,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 +185,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 +282,7 @@ export class DOMBlockToolAdapter extends BlockToolAdapter { */ #handleDeleteInContentEditable( input: HTMLElement, - key: DataKey, + key: string, range: StaticRange, isRestoreCaretToTheEnd: boolean = false ): void { @@ -282,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.model.removeBlock(this.config.userId, this.blockId); + this.#api.blocks.delete({ block: this.blockId }); return; } @@ -310,7 +318,12 @@ 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({ + block: this.blockId, + key, + start, + end, + }); let newCaretIndex: number | null = null; @@ -335,8 +348,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 +364,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 +389,12 @@ 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({ + text: data, + block: this.blockId, + key, + start, + }); } break; } @@ -388,7 +406,12 @@ 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({ + text: data, + block: this.blockId, + key, + start, + }); } break; } @@ -442,12 +465,16 @@ 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({ block: 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({ 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 @@ -457,24 +484,26 @@ 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, - { - /** - * @todo when implementing split/merge, think of how to not use toolname here - */ - name: this.#toolName, - data: { - [key]: { - $t: 't', - value: newValueAfter, - fragments: relatedFragments, - }, + 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 + */ + 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 @@ -483,7 +512,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 +525,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 +539,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 +581,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..eb2e91d1 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,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.#model.getFragments(blockIndex, dataKey, 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/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 6f969c3a..398ad067 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'; @@ -40,13 +40,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.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(); }); 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..81d2ef28 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 @@ -9,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 @@ -44,17 +57,27 @@ export interface BlocksAPI { // renderFromHTML(data: string): Promise; /** - * Removes Block by index, or current block if index is not passed - * @param index - index 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(index?: number): 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 @@ -92,13 +115,86 @@ 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 - */ - insertMany( - blocks: BlockNodeInit[], - index?: number, - ): void; // BlockAPI[]; + * @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(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 + * @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 params - getData parameters + * @param params.block - index or id of the block + * @param params.key - data key to get + */ + 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 params - removeData parameters + * @param params.block - index or id of the block + * @param params.key - data key to remove + */ + 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 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(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 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/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..6d2bb551 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 } from '@editorjs/model'; /** * Selection API interface @@ -7,8 +7,28 @@ import type { 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 + * @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..fe3bc6b5 --- /dev/null +++ b/packages/sdk/src/api/TextAPI.ts @@ -0,0 +1,110 @@ +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 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(params: TextContent & Omit): void; + + /** + * Removes text from a given range + * @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(params: TextPosition): string; + + /** + * Formats the given range + * @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(params: InlineToolData & Required): void; + + /** + * Unformats the given range + * @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(params: Pick & Required): void; + + /** + * Returns applied inline fragments for a given range + * @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(params: Partial> & TextPosition): InlineFragment[]; + + /** + * Returns text content of the text node + * @param params.block - block index or identifier + * @param params.key - data key of the text node + */ + get(params: Pick): 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..62534edd 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,62 @@ 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({ + block: this.blockId, + key, + value: 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({ + block: this.blockId, + key, + }) === undefined) { return; } - this.model.removeDataNode(this.config.userId, this.blockId, createDataKey(keyRaw)); + this.#api.blocks.removeData({ + block: this.blockId, + key, + }); } /** @@ -150,12 +150,19 @@ 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({ + block: this.blockId, + key, + }) !== undefined) { return; } - this.model.createDataNode(this.config.userId, this.blockId, key, initialData); + this.#api.blocks.createData({ + block: this.blockId, + key, + initialData, + }); } /** @@ -169,7 +176,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. 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, + }); }, } );