diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index 42499fad..a3508afd 100644 --- a/packages/core/src/api/BlocksAPI.spec.ts +++ b/packages/core/src/api/BlocksAPI.spec.ts @@ -18,6 +18,7 @@ jest.unstable_mockModule('@editorjs/model', () => ({ EditorJSModel: jest.fn(), createBlockId: jest.fn(id => id), createDataKey: jest.fn(key => key), + createBlockTuneName: jest.fn(name => name), })); const { BlocksManager } = await import('../components/BlockManager'); @@ -219,4 +220,109 @@ describe('BlocksAPI', () => { }); }); }); + + describe('.getTuneData()', () => { + it('should return the tune data for the given block and tune name', () => { + const tuneData = { align: 'left' }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = new EditorJSModel('userId', { identifier: 'docId' }) as any; + + model.getBlockSerialized = jest.fn().mockReturnValue({ tunes: { myTune: tuneData } }); + + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + model + ); + + const result = api.getTuneData({ block: 0, + tuneName: 'myTune' }); + + expect(result).toEqual(tuneData); + expect(model.getBlockSerialized).toHaveBeenCalledWith(0); + }); + + it('should return an empty object when the tune has no data', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = new EditorJSModel('userId', { identifier: 'docId' }) as any; + + model.getBlockSerialized = jest.fn().mockReturnValue({ tunes: {} }); + + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + model + ); + + const result = api.getTuneData({ block: 0, + tuneName: 'missingTune' }); + + expect(result).toEqual({}); + }); + + it('should return an empty object when the block has no tunes', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = new EditorJSModel('userId', { identifier: 'docId' }) as any; + + model.getBlockSerialized = jest.fn().mockReturnValue({}); + + const api = new BlocksAPI( + blocksManager, + { defaultBlock } as CoreConfigValidated, + model + ); + + const result = api.getTuneData({ block: 0, + tuneName: 'anyTune' }); + + expect(result).toEqual({}); + }); + }); + + describe('.updateTuneData()', () => { + it('should call model.updateTuneData with userId, block, tuneName, and data', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = new EditorJSModel('userId', { identifier: 'docId' }) as any; + + model.updateTuneData = jest.fn(); + + const api = new BlocksAPI( + blocksManager, + { + defaultBlock, + userId: 'user1', + } as CoreConfigValidated, + model + ); + + api.updateTuneData({ block: 1, + tuneName: 'align', + data: { align: 'center' } }); + + expect(model.updateTuneData).toHaveBeenCalledWith('user1', 1, 'align', { align: 'center' }); + }); + + it('should use the provided userId instead of the config userId', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const model = new EditorJSModel('userId', { identifier: 'docId' }) as any; + + model.updateTuneData = jest.fn(); + + const api = new BlocksAPI( + blocksManager, + { + defaultBlock, + userId: 'defaultUser', + } as CoreConfigValidated, + model + ); + + api.updateTuneData({ block: 0, + tuneName: 'align', + data: {}, + userId: 'overrideUser' }); + + expect(model.updateTuneData).toHaveBeenCalledWith('overrideUser', 0, 'align', {}); + }); + }); }); diff --git a/packages/core/src/api/BlocksAPI.ts b/packages/core/src/api/BlocksAPI.ts index 8d09afce..713eb107 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -8,6 +8,7 @@ import { BlockId, BlockIndexOrId, createBlockId, + createBlockTuneName, createDataKey, EditorDocumentSerialized, EditorJSModel, @@ -228,4 +229,26 @@ export class BlocksAPI implements BlocksApiInterface { }: Parameters[0]): void { this.#blocksManager.convertBlock(block as BlockIndexOrId, createDataKey(key), newType, userId, dataOverrides); } + + /** + * Returns the serialized data for the given tune on a block + * @param params - getTuneData parameters + * @param params.block - index or id of the block + * @param params.tuneName - tune to read data from + */ + public getTuneData({ block, tuneName }: Parameters[0]): Record { + return this.#model.getBlockSerialized(block as BlockIndexOrId).tunes?.[tuneName] ?? {}; + } + + /** + * Updates tune data for the given block and tune name + * @param params - updateTuneData parameters + * @param params.block - index or id of the block + * @param params.tuneName - selects which set of registered settings to modify + * @param params.data - new tune data (merged into existing data) + * @param [params.userId] - user id to attribute the change to + */ + public updateTuneData({ block, tuneName, data, userId = this.#config.userId }: Parameters[0]): void { + this.#model.updateTuneData(userId, block as BlockIndexOrId, createBlockTuneName(tuneName), data); + } } diff --git a/packages/core/src/components/BlockRenderer.spec.ts b/packages/core/src/components/BlockRenderer.spec.ts index ed4c105c..8d0547d8 100644 --- a/packages/core/src/components/BlockRenderer.spec.ts +++ b/packages/core/src/components/BlockRenderer.spec.ts @@ -57,6 +57,7 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({ create: jest.fn(() => ({ render: jest.fn(() => Promise.resolve({})) })) })), }, + blockTunes: new Map(), })), })); @@ -84,6 +85,9 @@ describe('BlockRenderer (unit, mocked deps)', () => { const adapter: EditorJSAdapterPlugin = { createBlockToolAdapter: jest.fn(() => ({})), destroyBlockToolAdapter: jest.fn(), + createBlockTuneAdapter: jest.fn(() => ({})), + getBlockTuneAdapter: jest.fn(() => undefined), + destroyBlockTuneAdapters: jest.fn(), } as unknown as EditorJSAdapterPlugin; new BlockRenderer( diff --git a/packages/core/src/components/BlockRenderer.ts b/packages/core/src/components/BlockRenderer.ts index fabd6b7b..965b18ec 100644 --- a/packages/core/src/components/BlockRenderer.ts +++ b/packages/core/src/components/BlockRenderer.ts @@ -111,7 +111,12 @@ export class BlockRenderer { throw new Error(`[BlockRenderer] Block Tool ${data.name} not found`); } - const blockToolAdapter = this.#adapter.createBlockToolAdapter(createBlockId(data.id), tool.name); + const blockId = createBlockId(data.id); + const blockToolAdapter = this.#adapter.createBlockToolAdapter(blockId, tool.name); + + for (const tuneName of this.#toolsManager.blockTunes.keys()) { + this.#adapter.createBlockTuneAdapter(blockId, tuneName); + } const block = tool.create({ adapter: blockToolAdapter, @@ -147,7 +152,10 @@ export class BlockRenderer { throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue'); } - this.#adapter.destroyBlockToolAdapter(createBlockId(data.id)); + const removedBlockId = createBlockId(data.id); + + this.#adapter.destroyBlockToolAdapter(removedBlockId); + this.#adapter.destroyBlockTuneAdapters(removedBlockId); this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({ tool: data.name, diff --git a/packages/core/src/components/BlockTunesManager.spec.ts b/packages/core/src/components/BlockTunesManager.spec.ts new file mode 100644 index 00000000..41e06533 --- /dev/null +++ b/packages/core/src/components/BlockTunesManager.spec.ts @@ -0,0 +1,129 @@ +/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; +import type { EditorAPI } from '../api/index.js'; + +let blockSelectedListener: (event: CustomEvent) => void; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + BlockSelectedCoreEvent: jest.fn(function (this: { detail: unknown }, payload: unknown) { + this.detail = payload; + }), + BlockSelectedUIEvent: jest.fn(), + CoreEventType: { BlockSelected: 'block:selected' }, + EventBus: jest.fn(() => ({ + addEventListener: jest.fn((name: string, handler: (e: CustomEvent) => void) => { + if (name === 'ui:blocks:block-selected') { + blockSelectedListener = handler; + } + }), + dispatchEvent: jest.fn(), + })), + EditorJSAdapterPlugin: jest.fn(), +})); + +const fakeFacade = { create: jest.fn(() => ({ render: jest.fn() })) }; + +jest.unstable_mockModule('../tools/ToolsManager', () => ({ + default: jest.fn(() => ({ + blockTunes: new Map([ + ['moveUp', fakeFacade], + ]), + })), +})); + +jest.unstable_mockModule('../api/index.js', () => ({ + EditorAPI: jest.fn(), +})); + +const { BlockSelectedCoreEvent, EventBus } = await import('@editorjs/sdk'); +const ToolsManager = (await import('../tools/ToolsManager')).default; +const { BlockTunesManager } = await import('./BlockTunesManager.js'); + +describe('BlockTunesManager', () => { + const mockBlockId = 'block-id-42'; + + const mockApi = { + blocks: { + getIdByIndex: jest.fn(() => mockBlockId), + }, + }; + + const mockTuneAdapter = {}; + + const mockAdapter = { + getBlockTuneAdapter: jest.fn(() => mockTuneAdapter), + createBlockTuneAdapter: jest.fn(() => mockTuneAdapter), + createBlockToolAdapter: jest.fn(), + destroyBlockToolAdapter: jest.fn(), + destroyBlockTuneAdapters: jest.fn(), + }; + + const eventBus = new EventBus(); + // @ts-expect-error — mocked instance + const toolsManager = new ToolsManager(); + + new BlockTunesManager( + eventBus, + toolsManager, + mockApi as unknown as EditorAPI, + mockAdapter as never + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIdByIndex.mockReturnValue(mockBlockId); + mockAdapter.getBlockTuneAdapter.mockReturnValue(mockTuneAdapter); + }); + + it('should emit BlockSelectedCoreEvent with tune instances when a block is selected', () => { + blockSelectedListener({ detail: { index: 2, + block: {} } } as unknown as CustomEvent); + + expect(mockApi.blocks.getIdByIndex).toHaveBeenCalledWith(2); + expect(BlockSelectedCoreEvent).toHaveBeenCalledWith(expect.objectContaining({ + index: 2, + blockId: mockBlockId, + availableBlockTunes: expect.any(Map), + })); + expect(eventBus.dispatchEvent).toHaveBeenCalled(); + }); + + it('should not emit when blockId is undefined', () => { + mockApi.blocks.getIdByIndex.mockReturnValue(undefined as unknown as string); + + blockSelectedListener({ detail: { index: 99, + block: {} } } as unknown as CustomEvent); + + expect(eventBus.dispatchEvent).not.toHaveBeenCalled(); + }); + + it('should call facade.create() for each registered tune with blockId, api, and adapter', () => { + blockSelectedListener({ detail: { index: 0, + block: {} } } as unknown as CustomEvent); + + expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi, mockTuneAdapter); + }); + + it('should use an existing adapter when getBlockTuneAdapter returns one', () => { + const existingAdapter = { existing: true }; + + mockAdapter.getBlockTuneAdapter.mockReturnValue(existingAdapter); + + blockSelectedListener({ detail: { index: 0, + block: {} } } as unknown as CustomEvent); + + expect(mockAdapter.createBlockTuneAdapter).not.toHaveBeenCalled(); + expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi, existingAdapter); + }); + + it('should create a new adapter when getBlockTuneAdapter returns undefined', () => { + mockAdapter.getBlockTuneAdapter.mockReturnValue(undefined as never); + + blockSelectedListener({ detail: { index: 0, + block: {} } } as unknown as CustomEvent); + + expect(mockAdapter.createBlockTuneAdapter).toHaveBeenCalled(); + expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi, mockTuneAdapter); + }); +}); diff --git a/packages/core/src/components/BlockTunesManager.ts b/packages/core/src/components/BlockTunesManager.ts new file mode 100644 index 00000000..688e7b2d --- /dev/null +++ b/packages/core/src/components/BlockTunesManager.ts @@ -0,0 +1,58 @@ +import 'reflect-metadata'; +import { inject, injectable } from 'inversify'; +import { + BlockSelectedCoreEvent, + BlockSelectedUIEvent, + EditorJSAdapterPlugin, + EventBus +} from '@editorjs/sdk'; +import ToolsManager from '../tools/ToolsManager.js'; +import { EditorAPI } from '../api/index.js'; +import { TOKENS } from '../tokens.js'; + +/** + * BlockTunesManager listens for block selection events, instantiates tune instances + * for the selected block, and emits a BlockSelectedCoreEvent so the UI can render them. + */ +@injectable() +export class BlockTunesManager { + /** + * @param eventBus - event bus to listen for block selection events on + * @param toolsManager - provides the registered block tune facades + * @param api - editor API + * @param adapter - adapter plugin used to create/get block tune adapters + */ + constructor( + eventBus: EventBus, + toolsManager: ToolsManager, + api: EditorAPI, + @inject(TOKENS.Adapter) adapter: EditorJSAdapterPlugin + ) { + eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => { + const { index } = event.detail; + const blockId = api.blocks.getIdByIndex(index); + + if (blockId === undefined) { + return; + } + + const availableBlockTunes = new Map( + Array.from(toolsManager.blockTunes.entries()).map(([name, facade]) => { + const tuneAdapter = adapter.getBlockTuneAdapter(blockId, name) + ?? adapter.createBlockTuneAdapter(blockId, name); + + return [ + name, + facade.create({}, blockId, api, tuneAdapter), + ]; + }) + ); + + eventBus.dispatchEvent(new BlockSelectedCoreEvent({ + index, + blockId, + availableBlockTunes, + })); + }); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d88bba37..82b11f22 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,6 +18,7 @@ import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, BlockT import { EditorAPI } from './api/index.js'; import { generateId } from './utils/uid.js'; import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool } from './tools/internal'; +import { MoveUpTune, DeleteBlockTune, MoveDownTune } from './tunes/internal'; import { ShortcutsPlugin } from './plugins/ShortcutsPlugin.js'; import { DOMAdapters } from '@editorjs/dom-adapters'; import { BlocksManager } from './components/BlockManager.js'; @@ -25,6 +26,7 @@ import { BlockRenderer } from './components/BlockRenderer.js'; import { SelectionManager } from './components/SelectionManager.js'; import { TOKENS } from './tokens.js'; import { UndoRedoManager } from './components/UndoRedoManager.js'; +import { BlockTunesManager } from './components/BlockTunesManager.js'; /** * If no holder is provided via config, the editor will be appended to the element with this id */ @@ -107,6 +109,9 @@ export default class Core { this.use(BoldInlineTool); this.use(ItalicInlineTool); this.use(LinkInlineTool); + this.use(MoveUpTune); + this.use(DeleteBlockTune); + this.use(MoveDownTune); this.use(ShortcutsPlugin); this.use(CollaborationManager); this.use(DOMAdapters); @@ -173,6 +178,7 @@ export default class Core { this.#iocContainer.get(BlocksManager); this.#iocContainer.get(BlockRenderer); this.#iocContainer.get(UndoRedoManager); + this.#iocContainer.get(BlockTunesManager); this.#initializePlugins(); await this.#initializeTools(); diff --git a/packages/core/src/tunes/internal/delete-block/index.spec.ts b/packages/core/src/tunes/internal/delete-block/index.spec.ts new file mode 100644 index 00000000..1eaa42c5 --- /dev/null +++ b/packages/core/src/tunes/internal/delete-block/index.spec.ts @@ -0,0 +1,58 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; +import type { BlockId } from '@editorjs/model'; +import type { EditorAPI } from '@editorjs/sdk'; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + ToolType: { Tune: 'tune' }, +})); + +jest.unstable_mockModule('@codexteam/icons', () => ({ + IconCross: '', +})); + +const { DeleteBlockTune } = await import('./index.js'); + +describe('DeleteBlockTune', () => { + const mockApi = { + blocks: { + getIndexById: jest.fn(() => 1), + delete: jest.fn(), + }, + }; + + const mockBlockId = 'block-id-456' as unknown as BlockId; + + const tune = new DeleteBlockTune({ + api: mockApi as unknown as EditorAPI, + blockId: mockBlockId, + data: undefined, + config: {}, + adapter: {} as never, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIndexById.mockReturnValue(1); + }); + + it('should have title and icon', () => { + expect(tune.title).toBe('Delete'); + expect(tune.icon).toBeDefined(); + }); + + it('should delete the block at the resolved index on activate()', () => { + tune.activate(); + + expect(mockApi.blocks.getIndexById).toHaveBeenCalledWith(String(mockBlockId)); + expect(mockApi.blocks.delete).toHaveBeenCalledWith({ block: 1 }); + }); + + it('should resolve index at activate-time, not construction-time', () => { + mockApi.blocks.getIndexById.mockReturnValue(4); + tune.activate(); + + expect(mockApi.blocks.delete).toHaveBeenCalledWith({ block: 4 }); + }); +}); diff --git a/packages/core/src/tunes/internal/delete-block/index.ts b/packages/core/src/tunes/internal/delete-block/index.ts new file mode 100644 index 00000000..385c47c4 --- /dev/null +++ b/packages/core/src/tunes/internal/delete-block/index.ts @@ -0,0 +1,40 @@ +import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import { IconCross } from '@codexteam/icons'; +import type { EditorAPI } from '@editorjs/sdk'; +import type { BlockId } from '@editorjs/model'; + +/** + * Internal tune that deletes the current block + */ +export class DeleteBlockTune implements BlockTune { + public static readonly type = ToolType.Tune; + public static readonly name = 'deleteBlock'; + + public readonly title = 'Delete'; + public readonly icon = IconCross; + + #api: EditorAPI; + #blockId: BlockId; + + /** + * @param options - api and blockId needed to wire up this tune + * @param options.api - editor API + * @param options.blockId - id of the block this tune is attached to + */ + constructor({ api, blockId }: BlockTuneConstructorOptions) { + this.#api = api; + this.#blockId = blockId; + } + + /** + * Deletes the block this tune is attached to + */ + public activate(): void { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + + this.#api.blocks.delete({ block: index }); + } +} + +DeleteBlockTune satisfies BlockTuneConstructor; diff --git a/packages/core/src/tunes/internal/index.ts b/packages/core/src/tunes/internal/index.ts new file mode 100644 index 00000000..b88820a1 --- /dev/null +++ b/packages/core/src/tunes/internal/index.ts @@ -0,0 +1,3 @@ +export * from './move-up/index.js'; +export * from './delete-block/index.js'; +export * from './move-down/index.js'; diff --git a/packages/core/src/tunes/internal/move-down/index.spec.ts b/packages/core/src/tunes/internal/move-down/index.spec.ts new file mode 100644 index 00000000..83ab8ad6 --- /dev/null +++ b/packages/core/src/tunes/internal/move-down/index.spec.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; +import type { BlockId } from '@editorjs/model'; +import type { EditorAPI } from '@editorjs/sdk'; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + ToolType: { Tune: 'tune' }, +})); + +jest.unstable_mockModule('@codexteam/icons', () => ({ + IconChevronDown: '', +})); + +const { MoveDownTune } = await import('./index.js'); + +describe('MoveDownTune', () => { + const mockApi = { + blocks: { + getIndexById: jest.fn(() => 1), + getBlocksCount: jest.fn(() => 3), + move: jest.fn(), + }, + }; + + const mockBlockId = 'block-id-789' as unknown as BlockId; + + const tune = new MoveDownTune({ + api: mockApi as unknown as EditorAPI, + blockId: mockBlockId, + data: undefined, + config: {}, + adapter: {} as never, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIndexById.mockReturnValue(1); + mockApi.blocks.getBlocksCount.mockReturnValue(3); + }); + + it('should have title and icon', () => { + expect(tune.title).toBe('Move down'); + expect(tune.icon).toBeDefined(); + }); + + it('should move block down by one position on activate()', () => { + tune.activate(); + + expect(mockApi.blocks.getIndexById).toHaveBeenCalledWith(String(mockBlockId)); + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 2, + fromIndex: 1 }); + }); + + it('should be disabled when block is last', () => { + mockApi.blocks.getIndexById.mockReturnValue(2); + mockApi.blocks.getBlocksCount.mockReturnValue(3); + + expect(tune.isDisabled()).toBe(true); + }); + + it('should not be disabled when block is not last', () => { + mockApi.blocks.getIndexById.mockReturnValue(1); + mockApi.blocks.getBlocksCount.mockReturnValue(3); + + expect(tune.isDisabled()).toBe(false); + }); + + it('should do nothing on activate() when block is already last', () => { + mockApi.blocks.getIndexById.mockReturnValue(2); + mockApi.blocks.getBlocksCount.mockReturnValue(3); + tune.activate(); + + expect(mockApi.blocks.move).not.toHaveBeenCalled(); + }); + + it('should resolve index at activate-time, not construction-time', () => { + mockApi.blocks.getIndexById.mockReturnValue(0); + mockApi.blocks.getBlocksCount.mockReturnValue(5); + tune.activate(); + + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 1, + fromIndex: 0 }); + }); +}); diff --git a/packages/core/src/tunes/internal/move-down/index.ts b/packages/core/src/tunes/internal/move-down/index.ts new file mode 100644 index 00000000..e17a21fa --- /dev/null +++ b/packages/core/src/tunes/internal/move-down/index.ts @@ -0,0 +1,53 @@ +import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import { IconChevronDown } from '@codexteam/icons'; +import type { EditorAPI } from '@editorjs/sdk'; +import type { BlockId } from '@editorjs/model'; + +/** + * Internal tune that moves the current block one position down + */ +export class MoveDownTune implements BlockTune { + public static readonly type = ToolType.Tune; + public static readonly name = 'moveDown'; + + public readonly title = 'Move down'; + public readonly icon = IconChevronDown; + + #api: EditorAPI; + #blockId: BlockId; + + /** + * @param options - api and blockId needed to wire up this tune + * @param options.api - editor API + * @param options.blockId - id of the block this tune is attached to + */ + constructor({ api, blockId }: BlockTuneConstructorOptions) { + this.#api = api; + this.#blockId = blockId; + } + + /** + * Whether the block is already last, so it cannot move down further + */ + public isDisabled(): boolean { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + + return index === this.#api.blocks.getBlocksCount() - 1; + } + + /** + * Moves the block this tune is attached to one position down + */ + public activate(): void { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + const count = this.#api.blocks.getBlocksCount(); + + if (index < count - 1) { + this.#api.blocks.move({ toIndex: index + 1, + fromIndex: index }); + } + } +} + +MoveDownTune satisfies BlockTuneConstructor; diff --git a/packages/core/src/tunes/internal/move-up/index.spec.ts b/packages/core/src/tunes/internal/move-up/index.spec.ts new file mode 100644 index 00000000..df0cef30 --- /dev/null +++ b/packages/core/src/tunes/internal/move-up/index.spec.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; +import type { BlockId } from '@editorjs/model'; +import type { EditorAPI } from '@editorjs/sdk'; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + ToolType: { Tune: 'tune' }, +})); + +jest.unstable_mockModule('@codexteam/icons', () => ({ + IconChevronUp: '', +})); + +const { MoveUpTune } = await import('./index.js'); + +describe('MoveUpTune', () => { + const mockApi = { + blocks: { + getIndexById: jest.fn(() => 2), + move: jest.fn(), + }, + }; + + const mockBlockId = 'block-id-123' as unknown as BlockId; + + const tune = new MoveUpTune({ + api: mockApi as unknown as EditorAPI, + blockId: mockBlockId, + data: undefined, + config: {}, + adapter: {} as never, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIndexById.mockReturnValue(2); + }); + + it('should have title and icon', () => { + expect(tune.title).toBe('Move up'); + expect(tune.icon).toBeDefined(); + }); + + it('should move block up by one position on activate()', () => { + tune.activate(); + + expect(mockApi.blocks.getIndexById).toHaveBeenCalledWith(String(mockBlockId)); + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 1, + fromIndex: 2 }); + }); + + it('should be disabled when block is first', () => { + mockApi.blocks.getIndexById.mockReturnValue(0); + + expect(tune.isDisabled()).toBe(true); + }); + + it('should not be disabled when block is not first', () => { + mockApi.blocks.getIndexById.mockReturnValue(1); + + expect(tune.isDisabled()).toBe(false); + }); + + it('should do nothing on activate() when block is already first', () => { + mockApi.blocks.getIndexById.mockReturnValue(0); + tune.activate(); + + expect(mockApi.blocks.move).not.toHaveBeenCalled(); + }); + + it('should resolve index at activate-time, not construction-time', () => { + mockApi.blocks.getIndexById.mockReturnValue(3); + tune.activate(); + + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 2, + fromIndex: 3 }); + }); +}); diff --git a/packages/core/src/tunes/internal/move-up/index.ts b/packages/core/src/tunes/internal/move-up/index.ts new file mode 100644 index 00000000..6fc43769 --- /dev/null +++ b/packages/core/src/tunes/internal/move-up/index.ts @@ -0,0 +1,50 @@ +import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import { IconChevronUp } from '@codexteam/icons'; +import type { EditorAPI } from '@editorjs/sdk'; +import type { BlockId } from '@editorjs/model'; + +/** + * Internal tune that moves the current block one position up + */ +export class MoveUpTune implements BlockTune { + public static readonly type = ToolType.Tune; + public static readonly name = 'moveUp'; + + public readonly title = 'Move up'; + public readonly icon = IconChevronUp; + + #api: EditorAPI; + #blockId: BlockId; + + /** + * @param options - api and blockId needed to wire up this tune + * @param options.api - editor API + * @param options.blockId - id of the block this tune is attached to + */ + constructor({ api, blockId }: BlockTuneConstructorOptions) { + this.#api = api; + this.#blockId = blockId; + } + + /** + * Whether the block is already first, so it cannot move up further + */ + public isDisabled(): boolean { + return this.#api.blocks.getIndexById(String(this.#blockId)) === 0; + } + + /** + * Moves the block this tune is attached to one position up + */ + public activate(): void { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + + if (index > 0) { + this.#api.blocks.move({ toIndex: index - 1, + fromIndex: index }); + } + } +} + +MoveUpTune satisfies BlockTuneConstructor; diff --git a/packages/dom-adapters/jest.config.ts b/packages/dom-adapters/jest.config.ts index 999ea6af..5e592db0 100644 --- a/packages/dom-adapters/jest.config.ts +++ b/packages/dom-adapters/jest.config.ts @@ -20,4 +20,7 @@ export default { }, ], }, + transformIgnorePatterns: [ + 'node_modules/(?!(@editorjs|inversify|@inversifyjs))', + ], } as JestConfigWithTsJest; diff --git a/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts new file mode 100644 index 00000000..a8a43993 --- /dev/null +++ b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts @@ -0,0 +1,203 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; +import type { ModelEvents } from '@editorjs/model'; + +/** + * Mock inversify to avoid ESM parse errors in jest + */ +jest.mock('inversify', () => ({ + injectable: () => () => undefined, + inject: () => () => undefined, +})); + +jest.mock('reflect-metadata', () => ({})); + +/** + * Mock @editorjs/model to avoid DOM/model dependencies + */ +jest.mock('@editorjs/model', () => ({ + TuneModifiedEvent: class TuneModifiedEvent {}, +})); + +/** + * Minimal stand-in for BlockTuneAdapter that the DOMBlockTuneAdapter extends. + * We keep it simple to isolate what DOMBlockTuneAdapter adds on top. + */ +jest.mock('@editorjs/sdk', () => { + class FakeBlockTuneAdapter extends EventTarget { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public _api: any; + public blockId: string = ''; + public tuneName: string = ''; + public _cleanup: (() => void) | null = null; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(api: any) { + super(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this._api = api; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + this._cleanup = api.document.onUpdate(jest.fn()); + } + + public setBlockId(id: string): void { + this.blockId = id; + } + + public setTuneName(name: string): void { + this.tuneName = name; + } + + public getData(): Record { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + return this._api.blocks.getTuneData({ block: this.blockId, + tuneName: this.tuneName }); + } + + public setData(data: Record): void { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + this._api.blocks.updateTuneData({ block: this.blockId, + tuneName: this.tuneName, + data }); + } + + public destroy(): void { + if (this._cleanup) { + this._cleanup(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected handleModelUpdate(_event: ModelEvents): void { /* noop */ } + } + + return { BlockTuneAdapter: FakeBlockTuneAdapter }; +}); + +import { DOMBlockTuneAdapter } from './index.js'; + +describe('DOMBlockTuneAdapter', () => { + const makeApi = (): { + api: { + blocks: { getTuneData: ReturnType; + updateTuneData: ReturnType; }; + document: { onUpdate: ReturnType }; + }; + cleanupFn: ReturnType; + onUpdate: ReturnType; + } => { + const cleanupFn = jest.fn(); + const onUpdate = jest.fn<() => () => void>().mockReturnValue(cleanupFn); + + return { + api: { + blocks: { + getTuneData: jest.fn<() => Record>().mockReturnValue({ result: true }), + updateTuneData: jest.fn(), + }, + document: { onUpdate }, + }, + cleanupFn, + onUpdate, + }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create a DOMBlockTuneAdapter instance', () => { + const { api } = makeApi(); + const adapter = new DOMBlockTuneAdapter(api as never); + + expect(adapter).toBeInstanceOf(DOMBlockTuneAdapter); + }); + + it('should subscribe to model updates on construction', () => { + const { api, onUpdate } = makeApi(); + + new DOMBlockTuneAdapter(api as never); + + expect(onUpdate).toHaveBeenCalledTimes(1); + }); + }); + + describe('.setBlockId()', () => { + it('should set the block id so getData uses it when reading tune data', () => { + const { api } = makeApi(); + const adapter = new DOMBlockTuneAdapter(api as never); + + adapter.setBlockId('block-abc' as never); + adapter.getData(); + + expect(api.blocks.getTuneData).toHaveBeenCalledWith( + expect.objectContaining({ block: 'block-abc' }) + ); + }); + }); + + describe('.setTuneName()', () => { + it('should set the tune name so getData uses it when reading tune data', () => { + const { api } = makeApi(); + const adapter = new DOMBlockTuneAdapter(api as never); + + adapter.setTuneName('align'); + adapter.getData(); + + expect(api.blocks.getTuneData).toHaveBeenCalledWith( + expect.objectContaining({ tuneName: 'align' }) + ); + }); + }); + + describe('.getData()', () => { + it('should return tune data from the API for the configured block and tune', () => { + const { api } = makeApi(); + const adapter = new DOMBlockTuneAdapter(api as never); + + adapter.setBlockId('block-1' as never); + adapter.setTuneName('fontSize'); + + const result = adapter.getData(); + + expect(api.blocks.getTuneData).toHaveBeenCalledWith({ block: 'block-1', + tuneName: 'fontSize' }); + expect(result).toEqual({ result: true }); + }); + }); + + describe('.setData()', () => { + it('should call updateTuneData on the API with block, tune name, and data', () => { + const { api } = makeApi(); + const adapter = new DOMBlockTuneAdapter(api as never); + + adapter.setBlockId('block-2' as never); + adapter.setTuneName('myTune'); + + adapter.setData({ key: 'value' }); + + expect(api.blocks.updateTuneData).toHaveBeenCalledWith({ + block: 'block-2', + tuneName: 'myTune', + data: { key: 'value' }, + }); + }); + }); + + describe('.destroy()', () => { + it('should invoke the cleanup function returned by api.document.onUpdate', () => { + const cleanupFn = jest.fn(); + const { api, onUpdate } = makeApi(); + + onUpdate.mockReturnValueOnce(cleanupFn); + + const adapter = new DOMBlockTuneAdapter(api as never); + + adapter.destroy(); + + expect(cleanupFn).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/dom-adapters/src/BlockTuneAdapter/index.ts b/packages/dom-adapters/src/BlockTuneAdapter/index.ts new file mode 100644 index 00000000..3f184e48 --- /dev/null +++ b/packages/dom-adapters/src/BlockTuneAdapter/index.ts @@ -0,0 +1,30 @@ +import 'reflect-metadata'; +import { injectable, inject } from 'inversify'; +import type { ModelEvents } from '@editorjs/model'; +import { BlockTuneAdapter } from '@editorjs/sdk'; +import type { EditorAPI } from '@editorjs/sdk'; +import { TOKENS } from '../tokens.js'; + +/** + * DOM-specific implementation of BlockTuneAdapter. + * Resolves the EditorAPI from the IoC container and delegates all + * model-update handling to the base class. + */ +@injectable() +export class DOMBlockTuneAdapter extends BlockTuneAdapter { + /** + * @param api - Editor's API (injected) + */ + constructor(@inject(TOKENS.EditorAPI) api: EditorAPI) { + super(api); + } + + /** + * Hook for DOM-specific reactions to model updates. + * Currently a no-op; DOM-specific handling can be added here if needed. + * @param _event - model event + */ + protected handleModelUpdate(_event: ModelEvents): void { + // DOM-specific handling can be added here + } +} diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index 398ad067..088b6bd3 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import type { BlockToolAdapter, + BlockTuneAdapter, EditorJSAdapterPlugin, EditorjsAdapterPluginConstructor, EditorjsPluginParams @@ -8,6 +9,7 @@ import type { import { EventBus } from '@editorjs/sdk'; import { PluginType } from '@editorjs/sdk'; import { DOMBlockToolAdapter } from './BlockToolAdapter/index.js'; +import { DOMBlockTuneAdapter } from './BlockTuneAdapter/index.js'; import { InputsRegistry } from './InputsRegistry/index.js'; import type { BlockId } from '@editorjs/model'; import { Container } from 'inversify'; @@ -19,6 +21,7 @@ import { CaretAdapter } from './CaretAdapter/index.js'; export * from './CaretAdapter/index.js'; export * from './FormattingAdapter/index.js'; export * from './BlockToolAdapter/index.js'; +export * from './BlockTuneAdapter/index.js'; /** * Plugin for the DOM adapters @@ -37,6 +40,12 @@ export class DOMAdapters implements EditorJSAdapterPlugin { */ #adapters: Map = new Map(); + /** + * Map of active BlockTuneAdapter instances keyed by block id, then by tune name. + * Used to properly destroy adapters when blocks are removed. + */ + #tuneAdapters: Map> = new Map(); + /** * @param params - Plugin parameters * @param params.config - Editor's config @@ -51,6 +60,10 @@ export class DOMAdapters implements EditorJSAdapterPlugin { .bind(DOMBlockToolAdapter) .toSelf() .inTransientScope(); + this.#iocContainer + .bind(DOMBlockTuneAdapter) + .toSelf() + .inTransientScope(); /** * Initialize singleton adapters @@ -97,6 +110,49 @@ export class DOMAdapters implements EditorJSAdapterPlugin { registry.removeBlock(blockId); } + + /** + * Creates a BlockTuneAdapter for the given block and tune name. + * @param blockId - unique id of the block + * @param tuneName - name of the tune + */ + public createBlockTuneAdapter(blockId: BlockId, tuneName: string): BlockTuneAdapter { + const adapter = this.#iocContainer.get(DOMBlockTuneAdapter); + + adapter.setBlockId(blockId); + adapter.setTuneName(tuneName); + + if (!this.#tuneAdapters.has(blockId)) { + this.#tuneAdapters.set(blockId, new Map()); + } + + this.#tuneAdapters.get(blockId)!.set(tuneName, adapter); + + return adapter; + } + + /** + * Returns the BlockTuneAdapter for the given block and tune, if one exists. + * @param blockId - unique id of the block + * @param tuneName - name of the tune + */ + public getBlockTuneAdapter(blockId: BlockId, tuneName: string): BlockTuneAdapter | undefined { + return this.#tuneAdapters.get(blockId)?.get(tuneName); + } + + /** + * Destroys all BlockTuneAdapters for the given block. + * Called by BlockRenderer when a block is removed from the model. + * @param blockId - unique id of the removed block + */ + public destroyBlockTuneAdapters(blockId: BlockId): void { + const tuneAdapters = this.#tuneAdapters.get(blockId); + + if (tuneAdapters !== undefined) { + tuneAdapters.forEach(adapter => adapter.destroy()); + this.#tuneAdapters.delete(blockId); + } + } } DOMAdapters satisfies EditorjsAdapterPluginConstructor; diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 87e6ed84..1ed1d91c 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -4,7 +4,7 @@ import { EditorDocument, EditorJSModel } from '@editorjs/model'; import Core from '@editorjs/core'; import { ref, onMounted } from 'vue'; import { Node } from './components'; -import { EditorjsUI, BlocksUI, InlineToolbarUI, ToolboxUI, ToolbarUI } from '@editorjs/ui'; +import { EditorjsUI, BlocksUI, InlineToolbarUI, ToolboxUI, ToolbarUI, BlockTunesUI } from '@editorjs/ui'; /** * Editor document for visualizing */ @@ -68,6 +68,7 @@ onMounted(() => { .use(InlineToolbarUI) .use(ToolbarUI) .use(ToolboxUI) + .use(BlockTunesUI) .initialize(); }); diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 932125d3..7a8caa5a 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -216,6 +216,36 @@ export interface BlocksAPI { userId?: string | number; }): void; + /** + * Returns the serialized data for the given tune on a block + * @param params.block - index or id of the block + * @param params.tuneName - tune to read data from + */ + getTuneData(params: { + /** Index or id of the block */ + block: number | string; + /** Name of the tune */ + tuneName: string; + }): Record; + + /** + * Updates tune data for the given block and tune name + * @param params.block - index or id of the block + * @param params.tuneName - tune to update data for + * @param params.data - new tune data (merged into existing data) + * @param [params.userId] - defaults to the current user id from the config + */ + updateTuneData(params: { + /** Index or id of the block */ + block: number | string; + /** Name of the tune */ + tuneName: string; + /** New tune data (merged into existing data) */ + data: Record; + /** User id */ + userId?: string | number; + }): void; + /** * Splits the block at the given data key and character offset. * If the tool supports splitting (canBeSplit = true) a new block of the same type is inserted after the current one. diff --git a/packages/sdk/src/entities/BlockTune.ts b/packages/sdk/src/entities/BlockTune.ts index 66ebbbf4..23276020 100644 --- a/packages/sdk/src/entities/BlockTune.ts +++ b/packages/sdk/src/entities/BlockTune.ts @@ -5,6 +5,9 @@ import type { } from '@editorjs/editorjs'; import type { ToolType } from '@/entities/EntityType.js'; import type { BaseToolConstructor, BaseToolOptions } from '@/entities/BaseTool'; +import type { BlockId } from '@editorjs/model'; +import type { EditorAPI } from '@/api/EditorAPI.js'; +import type { BlockTuneAdapter } from '@/entities/BlockTuneAdapter.js'; /** * Options available on **Block Tunes** (`static options` or `use()` overrides). @@ -44,11 +47,25 @@ export interface BlockTuneConstructorOptions< * Config could be passed by tools user through the Editor config */ config: Config; + + /** + * Editor API for performing block operations (move, delete, etc.) + */ + api: EditorAPI; + + /** + * ID of the block this tune instance is bound to + */ + blockId: BlockId; + + /** + * Adapter providing data persistence and external update subscription for this tune instance + */ + adapter: BlockTuneAdapter; } /** * Block Tune interface for version 3 - * @todo describe the interface when the adapter implementation is done */ export type BlockTune< /** @@ -65,7 +82,33 @@ export type BlockTune< */ // eslint-disable-next-line @typescript-eslint/no-unused-vars Config extends ToolConfig = any -> = Omit; +> = Omit & { + /** + * Returns an HTML element for the tune (legacy render-based pattern) + */ + render?(): HTMLElement; + + /** + * Label shown in the block settings popover + */ + title?: string; + + /** + * SVG icon shown in the block settings popover + */ + icon?: string; + + /** + * Called when the tune is activated (clicked in the popover) + */ + activate?(): void; + + /** + * Returns true when the tune action is not applicable in the current state. + * The corresponding popover item will be rendered as disabled. + */ + isDisabled?(): boolean; +}; /** * Block Tune constructor class diff --git a/packages/sdk/src/entities/BlockTuneAdapter.ts b/packages/sdk/src/entities/BlockTuneAdapter.ts new file mode 100644 index 00000000..262954a8 --- /dev/null +++ b/packages/sdk/src/entities/BlockTuneAdapter.ts @@ -0,0 +1,145 @@ +import type { BlockId, ModelEvents } from '@editorjs/model'; +import { TuneModifiedEvent } from '@editorjs/model'; +import type { EditorAPI } from '../api/index.js'; +import { TuneDataChangedEvent } from './EventBus/events/adapter/index.js'; + +/** + * Shape of the detail.data payload carried by TuneModifiedEvent + */ +interface TuneDataChangePayload { + /** + * Updated tune data + */ + value: unknown; + + /** + * Tune data before the update + */ + previous: unknown; +} + +/** + * Abstract BlockTuneAdapter class — provides data persistence and model-update + * subscription for a single block tune instance. + * + * Concrete subclasses (e.g. DOMBlockTuneAdapter in dom-adapters) extend this + * class and inject the required EditorAPI via the IoC container. + */ +export abstract class BlockTuneAdapter extends EventTarget { + /** + * Editor's API + */ + #api: EditorAPI; + + /** + * Unique identifier of the block this adapter is bound to + */ + protected blockId!: BlockId; + + /** + * Name of the tune this adapter is bound to + */ + protected tuneName!: string; + + /** + * Cleanup function returned by api.document.onUpdate() + */ + #modelChangeListenerCleanup: () => void; + + /** + * @param api - Editor's API + */ + constructor(api: EditorAPI) { + super(); + + this.#api = api; + + this.#modelChangeListenerCleanup = this.#api.document.onUpdate( + ((event: ModelEvents) => this.#handleModelUpdate(event)) as EventListener + ); + } + + /** + * Sets the block id this adapter is bound to + * @param id - unique identifier of the block to bind to + */ + public setBlockId(id: BlockId): void { + this.blockId = id; + } + + /** + * Sets the tune name this adapter is bound to + * @param name - tune to bind to + */ + public setTuneName(name: string): void { + this.tuneName = name; + } + + /** + * Returns the serialized tune data for the current block and tune + */ + public getData(): Record { + return this.#api.blocks.getTuneData({ + block: this.blockId, + tuneName: this.tuneName, + }); + } + + /** + * Updates tune data for the current block and tune + * @param data - new data (merged into existing data) + */ + public setData(data: Record): void { + this.#api.blocks.updateTuneData({ + block: this.blockId, + tuneName: this.tuneName, + data, + }); + } + + /** + * Releases resources held by this adapter. + * Removes the model change listener registered in the constructor. + */ + public destroy(): void { + this.#modelChangeListenerCleanup(); + } + + /** + * Handles model update events. + * When a TuneModifiedEvent arrives for this adapter's block and tune, + * dispatches a TuneDataChangedEvent and delegates to the subclass hook. + * @param event - model event + */ + #handleModelUpdate(event: ModelEvents): void { + if (!(event instanceof TuneModifiedEvent)) { + return; + } + + const { blockIndex, tuneName } = event.detail.index; + + if (blockIndex === undefined || tuneName === undefined) { + return; + } + + const eventBlockId = this.#api.blocks.getIdByIndex(blockIndex); + + if (eventBlockId !== this.blockId || tuneName !== this.tuneName) { + return; + } + + const tuneDataChange = event.detail.data as TuneDataChangePayload; + const { value, previous } = tuneDataChange; + const tuneKey = event.detail.index.tuneKey ?? ''; + + this.dispatchEvent(new TuneDataChangedEvent(tuneKey, value, previous)); + + this.handleModelUpdate(event); + } + + /** + * Hook for subclasses to react to model updates for their block/tune + * @param eventDetails - carries the changed model data + */ + protected abstract handleModelUpdate(eventDetails: ModelEvents): void; +} diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts index 2e6696aa..ff9e38e5 100644 --- a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts +++ b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts @@ -2,6 +2,7 @@ import type { EditorjsPlugin, EditorjsPluginConstructor } from '@/entities/Edito import type { BlockId } from '@editorjs/model'; import type { PluginType } from '@/entities/EntityType'; import type { BlockToolAdapter } from '@/entities/BlockToolAdapter'; +import type { BlockTuneAdapter } from '@/entities/BlockTuneAdapter'; /** * Base interface for adapter plugins @@ -21,6 +22,28 @@ export interface EditorJSAdapterPlugin extends EditorjsPlugin { * @param blockId - unique identifier of the removed block */ destroyBlockToolAdapter(blockId: BlockId): void; + + /** + * Factory for the BlockTuneAdapter. Creates and registers an adapter for a + * specific tune on a specific block. + * @param blockId - unique identifier of the block + * @param tuneName - tune to create the adapter for + */ + createBlockTuneAdapter(blockId: BlockId, tuneName: string): BlockTuneAdapter; + + /** + * Returns the BlockTuneAdapter for the given block and tune, if one exists. + * @param blockId - unique identifier of the block + * @param tuneName - tune to look up the adapter for + */ + getBlockTuneAdapter(blockId: BlockId, tuneName: string): BlockTuneAdapter | undefined; + + /** + * Destroys all BlockTuneAdapters for the given block. + * Called when a block is removed from the model. + * @param blockId - unique identifier of the removed block + */ + destroyBlockTuneAdapters(blockId: BlockId): void; } /** diff --git a/packages/sdk/src/entities/EntityType.ts b/packages/sdk/src/entities/EntityType.ts index 02c8a2e0..de847d0e 100644 --- a/packages/sdk/src/entities/EntityType.ts +++ b/packages/sdk/src/entities/EntityType.ts @@ -25,7 +25,12 @@ export enum UiComponentType { /** * Toolbar area wrapper. Includes Toolbox and Block Settings */ - Toolbar = 'toolbar' + Toolbar = 'toolbar', + + /** + * Block tunes panel + */ + BlockTunes = 'block-tunes' } /** diff --git a/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts b/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts index 1b712d32..fff543ad 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/AdapterEventType.ts @@ -5,5 +5,10 @@ export enum AdapterEventType { /** * Updated event fired when text or value key is added/removed, or when the value is updated */ - Updated = 'adapter:updated' + Updated = 'adapter:updated', + + /** + * TuneUpdated event fired when tune data is modified + */ + TuneUpdated = 'adapter:tune-updated' } diff --git a/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts new file mode 100644 index 00000000..b838b039 --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts @@ -0,0 +1,40 @@ +import { AdapterEventType } from './AdapterEventType.js'; + +/** + * Payload of the TuneDataChanged event + */ +export interface TuneDataChangedPayload { + /** + * Data key whose value changed + */ + key: string; + + /** + * New value + */ + value: unknown; + + /** + * Previous value + */ + previous: unknown; +} + +/** + * TuneDataChangedEvent adapter event, dispatched when tune data is modified in the model + */ +export class TuneDataChangedEvent extends CustomEvent { + /** + * Creates a tune data change event for the given key + * @param key - which data field changed + * @param value - new value + * @param previous - value before the change + */ + constructor(key: string, value: unknown, previous: unknown) { + super(AdapterEventType.TuneUpdated, { + detail: { key, + value, + previous }, + }); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/adapter/index.ts b/packages/sdk/src/entities/EventBus/events/adapter/index.ts index 941c938e..f78764ac 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/index.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/index.ts @@ -2,3 +2,5 @@ export { AdapterEventType } from './AdapterEventType.js'; export { KeyAddedEvent } from './KeyAdded.js'; export { KeyRemovedEvent } from './KeyRemoved.js'; export { ValueNodeChangedEvent } from './ValueNodeChanged.js'; +export { TuneDataChangedEvent } from './TuneDataChanged.js'; +export type { TuneDataChangedPayload } from './TuneDataChanged.js'; diff --git a/packages/sdk/src/entities/EventBus/events/core/BlockSelectedCoreEvent.ts b/packages/sdk/src/entities/EventBus/events/core/BlockSelectedCoreEvent.ts new file mode 100644 index 00000000..cbb4d710 --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/core/BlockSelectedCoreEvent.ts @@ -0,0 +1,37 @@ +import type { BlockTune as IBlockTune } from '@/entities/BlockTune.js'; +import type { BlockId } from '@editorjs/model'; +import { CoreEventBase } from './CoreEventBase.js'; +import { CoreEventType } from './CoreEventType.js'; + +/** + * Payload of BlockSelectedCoreEvent custom event + */ +export interface BlockSelectedCoreEventPayload { + /** + * Index of the selected block + */ + readonly index: number; + + /** + * BlockId of the selected block + */ + readonly blockId: BlockId | undefined; + + /** + * Tune instances available for the selected block, keyed by tune name + */ + readonly availableBlockTunes: Map; +} + +/** + * Event fired when a block is selected and its tune instances are ready for rendering + */ +export class BlockSelectedCoreEvent extends CoreEventBase { + /** + * BlockSelectedCoreEvent constructor function + * @param payload - event payload with block index, id, and tune instances + */ + constructor(payload: BlockSelectedCoreEventPayload) { + super(CoreEventType.BlockSelected, payload); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts b/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts index 403e3c81..e22d7a06 100644 --- a/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts +++ b/packages/sdk/src/entities/EventBus/events/core/CoreEventType.ts @@ -38,5 +38,10 @@ export enum CoreEventType { /** * Event is fired when the Editor is fully initialized (document and tools are ready) */ - Ready = 'ready' + Ready = 'ready', + + /** + * Event is fired when a block is selected and its tune instances have been created + */ + BlockSelected = 'block:selected' } diff --git a/packages/sdk/src/entities/EventBus/events/core/index.ts b/packages/sdk/src/entities/EventBus/events/core/index.ts index 0dfaae58..6c0f5019 100644 --- a/packages/sdk/src/entities/EventBus/events/core/index.ts +++ b/packages/sdk/src/entities/EventBus/events/core/index.ts @@ -1,5 +1,6 @@ export * from './BlockAddedCoreEvent.js'; export * from './BlockRemovedCoreEvent.js'; +export * from './BlockSelectedCoreEvent.js'; export * from './ToolLoadedCoreEvent.js'; export * from './CoreEventType.js'; export * from './SelectionChangedCoreEvent.js'; diff --git a/packages/sdk/src/entities/EventBus/events/ui/BlockSelectedUIEvent.ts b/packages/sdk/src/entities/EventBus/events/ui/BlockSelectedUIEvent.ts new file mode 100644 index 00000000..1430fb94 --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/ui/BlockSelectedUIEvent.ts @@ -0,0 +1,30 @@ +import { UIEventBase } from './UIEventBase.js'; + +/** + * Payload of the BlockSelectedUIEvent + * Contains index and element of the selected block + */ +export interface BlockSelectedUIEventPayload { + /** + * Block wrapper element + */ + readonly block: HTMLElement; + + /** + * Index of a selected block + */ + readonly index: number; +} + +/** + * Event fired when a Block is selected in the UI (on mouseenter) + */ +export class BlockSelectedUIEvent extends UIEventBase { + /** + * BlockSelectedUIEvent constructor function + * @param payload - BlockSelectedUIEvent events payload + */ + constructor(payload: BlockSelectedUIEventPayload) { + super('blocks:block-selected', payload); + } +} diff --git a/packages/sdk/src/entities/EventBus/events/ui/index.ts b/packages/sdk/src/entities/EventBus/events/ui/index.ts index 69856918..32491c4a 100644 --- a/packages/sdk/src/entities/EventBus/events/ui/index.ts +++ b/packages/sdk/src/entities/EventBus/events/ui/index.ts @@ -1,3 +1,4 @@ export * from './UIEventBase.js'; export * from './BeforeInputUIEvent.js'; export * from './KeydownUIEvent.js'; +export * from './BlockSelectedUIEvent.js'; diff --git a/packages/sdk/src/entities/index.ts b/packages/sdk/src/entities/index.ts index 587a7cd1..6a84c3ef 100644 --- a/packages/sdk/src/entities/index.ts +++ b/packages/sdk/src/entities/index.ts @@ -4,6 +4,7 @@ export type * from './BlockTool.js'; export type * from './BlockTune.js'; export type * from './Config.js'; export * from './BlockToolAdapter.js'; +export * from './BlockTuneAdapter.js'; export * from './EventBus/index.js'; export type * from './EditorjsPlugin.js'; export * from './EntityType.js'; diff --git a/packages/sdk/src/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index 4a379317..8576fd63 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -8,7 +8,8 @@ import { ToolType } from '../../entities/EntityType.js'; import { type BlockTuneFacade } from './BlockTuneFacade.js'; import type { BlockTool, BlockToolConstructor, InlineTool, InlineToolConstructor, BlockTuneConstructor, - ToolTypeToOptions + ToolTypeToOptions, + BlockTune } from '../../entities'; import type { ToolStaticOptions, BlockToolOptions, InlineToolOptions, BlockTuneOptions } from '../../entities/BaseTool.js'; import { BaseToolOptionKey } from '../../entities/BaseTool.js'; @@ -80,7 +81,7 @@ interface ConstructorOptions { /** * Base abstract class for Tools */ -export abstract class BaseToolFacade { +export abstract class BaseToolFacade { /** * Tool name specified in EditorJS config */ diff --git a/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts new file mode 100644 index 00000000..b4f46091 --- /dev/null +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts @@ -0,0 +1,70 @@ +/* eslint-disable jsdoc/require-jsdoc */ + +import { describe, expect, it, jest } from '@jest/globals'; +import { ToolType } from '../../entities/EntityType.js'; +import { BlockTuneFacade } from './BlockTuneFacade.js'; +import type { BlockTuneConstructor, BlockTuneConstructorOptions } from '../../entities/BlockTune.js'; +import type { EditorAPI } from '../../api/EditorAPI.js'; +import type { BlockTuneAdapter } from '../../entities/BlockTuneAdapter.js'; +import type { BlockId } from '@editorjs/model'; + +const mockBlockId = 'test-block-id' as unknown as BlockId; +const mockApi = {} as EditorAPI; +const mockAdapter = {} as BlockTuneAdapter; + +function createTuneFacade(): { facade: BlockTuneFacade; + constructorSpy: jest.Mock; } { + const constructorSpy = jest.fn(); + + class MockTune { + public static readonly type = ToolType.Tune; + public static readonly name = 'mockTune'; + + constructor(options: BlockTuneConstructorOptions) { + constructorSpy(options); + } + + public render(): HTMLElement { + return document.createElement('button'); + } + } + + const facade = new BlockTuneFacade({ + name: 'mockTune', + constructable: MockTune as unknown as BlockTuneConstructor, + useToolOptions: {}, + api: {} as EditorAPI, + isDefault: false, + defaultPlaceholder: false, + }); + + return { facade, + constructorSpy }; +} + +describe('BlockTuneFacade', () => { + describe('create()', () => { + it('should pass data, blockId, api and adapter to the tune constructor', () => { + const { facade, constructorSpy } = createTuneFacade(); + const tuneData = { foo: 'bar' }; + + facade.create(tuneData, mockBlockId, mockApi, mockAdapter); + + expect(constructorSpy).toHaveBeenCalledWith(expect.objectContaining({ + data: tuneData, + blockId: mockBlockId, + api: mockApi, + adapter: mockAdapter, + })); + }); + + it('should return the tune instance created by the constructor', () => { + const { facade } = createTuneFacade(); + + const instance = facade.create({}, mockBlockId, mockApi, mockAdapter); + + expect(instance).toBeDefined(); + expect(typeof instance.render).toBe('function'); + }); + }); +}); diff --git a/packages/sdk/src/tools/facades/BlockTuneFacade.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.ts index 3f14ec46..ec7e47bb 100644 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.ts +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.ts @@ -1,12 +1,12 @@ import { BaseToolFacade } from './BaseToolFacade.js'; -import type { BlockAPI } from '@editorjs/editorjs'; import type { BlockTuneConstructor, BlockTune as IBlockTune, BlockTuneData } from '../../entities'; import { ToolType } from '../../entities'; -// import type { BlockTuneData } from '@editorjs/editorjs'; +import type { BlockId } from '@editorjs/model'; +import type { EditorAPI } from '../../api/EditorAPI.js'; +import type { BlockTuneAdapter } from '../../entities/BlockTuneAdapter.js'; /** - * Stub class for BlockTunes - * @todo Implement + * Facade for BlockTune tools */ export class BlockTuneFacade extends BaseToolFacade { /** @@ -20,17 +20,20 @@ export class BlockTuneFacade extends BaseToolFacade { protected declare constructable: BlockTuneConstructor; /** - * Constructs new BlockTune instance from constructable - * @param data - Tunes data - * @param _block - Block API object + * Constructs a new BlockTune instance for a specific block + * @param data - Tune's persistent data + * @param blockId - ID of the block this tune is bound to + * @param api - Editor API for performing block operations + * @param adapter - Adapter providing data persistence for this tune */ - public create(data: BlockTuneData, _block: BlockAPI): IBlockTune { + public create(data: BlockTuneData, blockId: BlockId, api: EditorAPI, adapter: BlockTuneAdapter): IBlockTune { return new this.constructable({ - // api: this.api, config: this.config, - // block, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data, + api, + blockId, + adapter, }); } } diff --git a/packages/ui/src/BlockTunes/BlockTunesUI.ts b/packages/ui/src/BlockTunes/BlockTunesUI.ts new file mode 100644 index 00000000..3f6db623 --- /dev/null +++ b/packages/ui/src/BlockTunes/BlockTunesUI.ts @@ -0,0 +1,117 @@ +import { make } from '@editorjs/dom'; +import type { + BlockSelectedCoreEvent, + BlockTune, + CoreConfigValidated, + EditorAPI, + EditorjsPlugin, + EditorjsPluginParams, + EventBus +} from '@editorjs/sdk'; +import { CoreEventType, UiComponentType } from '@editorjs/sdk'; +import { PopoverDesktop, PopoverEvent, PopoverItemType } from '@editorjs/ui-kit'; +import { + BlockTunesClosedUIEvent, + BlockTunesOpenedUIEvent, + BlockTunesRenderedUIEvent +} from './events/index.js'; + +/** + * UI plugin that renders the block settings popover. + * Mirrors ToolboxUI: dispatches a rendered event so ToolbarUI can append the element, + * then opens the popover when it receives a ui:block-tunes:open event. + */ +export class BlockTunesUI implements EditorjsPlugin { + public static readonly type = UiComponentType.BlockTunes; + + #api: EditorAPI; + #eventBus: EventBus; + #editorConfig: CoreConfigValidated; + + #popover: PopoverDesktop; + + #nodes: Record = {}; + + /** + * Names of items currently registered in the popover, used to clear on each selection + */ + #currentItemNames: string[] = []; + + constructor({ api, eventBus, config }: EditorjsPluginParams) { + this.#api = api; + this.#eventBus = eventBus; + this.#editorConfig = config; + + this.#popover = new PopoverDesktop( + { + scopeElement: this.#editorConfig.holder, + searchable: false, + items: [], + }, + { + [PopoverItemType.Default]: { wrapperTag: 'button' }, + } + ); + + this.#popover.on(PopoverEvent.Closed, () => { + this.#eventBus.dispatchEvent(new BlockTunesClosedUIEvent({})); + }); + + this.#render(); + + this.#eventBus.addEventListener(`core:${CoreEventType.BlockSelected}`, (event: BlockSelectedCoreEvent) => { + this.#rebuildItems(event.detail.availableBlockTunes); + }); + + this.#eventBus.addEventListener('ui:block-tunes:open', () => { + this.#open(); + }); + } + + public destroy(): void { + this.#nodes.holder?.remove(); + } + + #open(): void { + this.#popover.show(); + this.#eventBus.dispatchEvent(new BlockTunesOpenedUIEvent({})); + } + + /** + * Clears current popover items and rebuilds from the new tunes map + * @param tunes - map of tune name to tune instance + */ + #rebuildItems(tunes: Map): void { + for (const name of this.#currentItemNames) { + this.#popover.removeItemByName(name); + } + + this.#currentItemNames = []; + + tunes.forEach((tune, name) => { + this.#popover.addItem({ + name, + title: tune.title, + icon: tune.icon, + closeOnActivate: true, + isDisabled: tune.isDisabled?.(), + onActivate: () => { + tune.activate?.(); + }, + }); + + this.#currentItemNames.push(name); + }); + } + + #render(): void { + this.#nodes.holder = make('div'); + this.#nodes.holder.appendChild(this.#popover.getElement()); + + this.#eventBus.dispatchEvent(new BlockTunesRenderedUIEvent({ + blockTunes: this.#nodes.holder, + })); + } +} + +export * from './events/index.js'; diff --git a/packages/ui/src/BlockTunes/events/BlockTunesBaseEvent.ts b/packages/ui/src/BlockTunes/events/BlockTunesBaseEvent.ts new file mode 100644 index 00000000..aa211d7e --- /dev/null +++ b/packages/ui/src/BlockTunes/events/BlockTunesBaseEvent.ts @@ -0,0 +1,10 @@ +import { UIEventBase } from '@editorjs/sdk'; + +/** + * Base event class for BlockTunes events + */ +export class BlockTunesBaseEvent extends UIEventBase { + constructor(name: string, payload: Payload) { + super(`block-tunes:${name}`, payload); + } +} diff --git a/packages/ui/src/BlockTunes/events/BlockTunesClosedUIEvent.ts b/packages/ui/src/BlockTunes/events/BlockTunesClosedUIEvent.ts new file mode 100644 index 00000000..efb07c7b --- /dev/null +++ b/packages/ui/src/BlockTunes/events/BlockTunesClosedUIEvent.ts @@ -0,0 +1,10 @@ +import { BlockTunesBaseEvent } from './BlockTunesBaseEvent.js'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BlockTunesClosedUIEventPayload {} + +export class BlockTunesClosedUIEvent extends BlockTunesBaseEvent { + constructor(payload: BlockTunesClosedUIEventPayload) { + super('closed', payload); + } +} diff --git a/packages/ui/src/BlockTunes/events/BlockTunesOpenUIEvent.ts b/packages/ui/src/BlockTunes/events/BlockTunesOpenUIEvent.ts new file mode 100644 index 00000000..7641dd75 --- /dev/null +++ b/packages/ui/src/BlockTunes/events/BlockTunesOpenUIEvent.ts @@ -0,0 +1,10 @@ +import { BlockTunesBaseEvent } from './BlockTunesBaseEvent.js'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BlockTunesOpenUIEventPayload {} + +export class BlockTunesOpenUIEvent extends BlockTunesBaseEvent { + constructor(payload: BlockTunesOpenUIEventPayload) { + super('open', payload); + } +} diff --git a/packages/ui/src/BlockTunes/events/BlockTunesOpenedUIEvent.ts b/packages/ui/src/BlockTunes/events/BlockTunesOpenedUIEvent.ts new file mode 100644 index 00000000..936e3be7 --- /dev/null +++ b/packages/ui/src/BlockTunes/events/BlockTunesOpenedUIEvent.ts @@ -0,0 +1,10 @@ +import { BlockTunesBaseEvent } from './BlockTunesBaseEvent.js'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface BlockTunesOpenedUIEventPayload {} + +export class BlockTunesOpenedUIEvent extends BlockTunesBaseEvent { + constructor(payload: BlockTunesOpenedUIEventPayload) { + super('opened', payload); + } +} diff --git a/packages/ui/src/BlockTunes/events/BlockTunesRenderedUIEvent.ts b/packages/ui/src/BlockTunes/events/BlockTunesRenderedUIEvent.ts new file mode 100644 index 00000000..77422fb2 --- /dev/null +++ b/packages/ui/src/BlockTunes/events/BlockTunesRenderedUIEvent.ts @@ -0,0 +1,11 @@ +import { BlockTunesBaseEvent } from './BlockTunesBaseEvent.js'; + +export interface BlockTunesRenderedUIEventPayload { + readonly blockTunes: HTMLElement; +} + +export class BlockTunesRenderedUIEvent extends BlockTunesBaseEvent { + constructor(payload: BlockTunesRenderedUIEventPayload) { + super('rendered', payload); + } +} diff --git a/packages/ui/src/BlockTunes/events/index.ts b/packages/ui/src/BlockTunes/events/index.ts new file mode 100644 index 00000000..86bb9b21 --- /dev/null +++ b/packages/ui/src/BlockTunes/events/index.ts @@ -0,0 +1,5 @@ +export * from './BlockTunesBaseEvent.js'; +export * from './BlockTunesRenderedUIEvent.js'; +export * from './BlockTunesOpenUIEvent.js'; +export * from './BlockTunesOpenedUIEvent.js'; +export * from './BlockTunesClosedUIEvent.js'; diff --git a/packages/ui/src/Blocks/events/BlockSelectedUIEvent.ts b/packages/ui/src/Blocks/events/BlockSelectedUIEvent.ts index 64b4228f..10b76cd3 100644 --- a/packages/ui/src/Blocks/events/BlockSelectedUIEvent.ts +++ b/packages/ui/src/Blocks/events/BlockSelectedUIEvent.ts @@ -1,30 +1 @@ -import { BlocksBaseEvent } from './BlocksBaseEvent.js'; - -/** - * Payload of the BlockSelectedUIEvent - * Contains index of a selected block - */ -export interface BlockSelectedUIEventPayload { - /** - * Block wrapper element - */ - readonly block: HTMLElement; - - /** - * Index of a selected block - */ - readonly index: number; -} - -/** - * Class for events that is being fired when a Block is selected in the ui (on mouseenter event) - */ -export class BlockSelectedUIEvent extends BlocksBaseEvent { - /** - * BlockSelectedUIEvent constructor function - * @param payload - BlockSelectedUIEvent events payload - */ - constructor(payload: BlockSelectedUIEventPayload) { - super('block-selected', payload); - } -} +export { BlockSelectedUIEvent, type BlockSelectedUIEventPayload } from '@editorjs/sdk'; diff --git a/packages/ui/src/Toolbar/Toolbar.const.ts b/packages/ui/src/Toolbar/Toolbar.const.ts index 02a9b221..658273ec 100644 --- a/packages/ui/src/Toolbar/Toolbar.const.ts +++ b/packages/ui/src/Toolbar/Toolbar.const.ts @@ -6,4 +6,5 @@ export const css = { toolbar: className(), actions: className('actions'), plusButton: className('plus-button'), + settingsButton: className('settings-button'), }; diff --git a/packages/ui/src/Toolbar/Toolbar.module.pcss b/packages/ui/src/Toolbar/Toolbar.module.pcss index 07c97157..0cab49f4 100644 --- a/packages/ui/src/Toolbar/Toolbar.module.pcss +++ b/packages/ui/src/Toolbar/Toolbar.module.pcss @@ -42,6 +42,8 @@ &__actions { position: absolute; right: 100%; + display: flex; + align-items: center; } &__plus-button { @@ -49,4 +51,10 @@ flex-shrink: 0; } + + &__settings-button { + @apply --toolbar-button; + + flex-shrink: 0; + } } diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index 50e2f81d..23b208fa 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -3,11 +3,13 @@ import { UiComponentType } from '@editorjs/sdk'; import { make } from '@editorjs/dom'; import { css } from './Toolbar.const.js'; import type { ToolboxRenderedUIEvent } from '../Toolbox/events/index.js'; -import { IconPlus } from '@codexteam/icons'; +import { IconPlus, IconMenuSmall } from '@codexteam/icons'; import Style from './Toolbar.module.pcss'; import { ToolbarRenderedUIEvent } from './ToolbarRenderedUIEvent.js'; import type { BlockSelectedUIEvent } from '../Blocks/events/index.js'; import { ToolboxOpenUIEvent } from '../Toolbox/events/index.js'; +import type { BlockTunesRenderedUIEvent } from '../BlockTunes/events/index.js'; +import { BlockTunesOpenUIEvent } from '../BlockTunes/events/index.js'; /** * HTML Nodes toolbar uses in the UI @@ -27,6 +29,11 @@ interface ToolbarNodes { * Plus button to open Toolbox popover */ plusButton: HTMLButtonElement; + + /** + * Settings button to open Block Tunes popover + */ + settingsButton: HTMLButtonElement; } /** @@ -54,6 +61,9 @@ export class ToolbarUI implements EditorjsPlugin { plusButton: make('button', Style[css.plusButton], { innerHTML: IconPlus, }) as HTMLButtonElement, + settingsButton: make('button', Style[css.settingsButton], { + innerHTML: IconMenuSmall, + }) as HTMLButtonElement, }; /** @@ -78,13 +88,17 @@ export class ToolbarUI implements EditorjsPlugin { this.#subscribeToToolboxEvents(); - this.#eventBus.addEventListener(`ui:blocks:block-selected`, (event: BlockSelectedUIEvent) => { + this.#eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => { if (this.#isToolboxOpen) { return; } this.moveTo(event.detail.block); }); + + this.#eventBus.addEventListener('ui:block-tunes:rendered', (event: BlockTunesRenderedUIEvent) => { + this.#nodes.actions.appendChild(event.detail.blockTunes); + }); } /** @@ -117,11 +131,16 @@ export class ToolbarUI implements EditorjsPlugin { #render(): void { this.#nodes.holder.appendChild(this.#nodes.actions); this.#nodes.actions.appendChild(this.#nodes.plusButton); + this.#nodes.actions.appendChild(this.#nodes.settingsButton); this.#nodes.plusButton.addEventListener('click', () => { this.#openToolbox(); }); + this.#nodes.settingsButton.addEventListener('click', () => { + this.#openBlockTunes(); + }); + this.#eventBus.dispatchEvent(new ToolbarRenderedUIEvent({ toolbar: this.#nodes.holder, })); @@ -131,15 +150,15 @@ export class ToolbarUI implements EditorjsPlugin { * Subscribes to Toolbox event */ #subscribeToToolboxEvents(): void { - this.#eventBus.addEventListener(`ui:toolbox:rendered`, (event: ToolboxRenderedUIEvent) => { + this.#eventBus.addEventListener('ui:toolbox:rendered', (event: ToolboxRenderedUIEvent) => { this.#addToolbox(event.detail.toolbox); }); - this.#eventBus.addEventListener(`ui:toolbox:opened`, () => { + this.#eventBus.addEventListener('ui:toolbox:opened', () => { this.#isToolboxOpen = true; }); - this.#eventBus.addEventListener(`ui:toolbox:closed`, () => { + this.#eventBus.addEventListener('ui:toolbox:closed', () => { this.#isToolboxOpen = false; }); } @@ -148,6 +167,13 @@ export class ToolbarUI implements EditorjsPlugin { * Dispatches an event to Toolbox plugin to open the toolbox */ #openToolbox(): void { - this.#eventBus.dispatchEvent(new ToolboxOpenUIEvent('ui:toolbox:open')); + this.#eventBus.dispatchEvent(new ToolboxOpenUIEvent({})); + } + + /** + * Dispatches an event to BlockTunes plugin to open the block settings popover + */ + #openBlockTunes(): void { + this.#eventBus.dispatchEvent(new BlockTunesOpenUIEvent({})); } } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bc13c213..2b718065 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -104,3 +104,4 @@ export * from './InlineToolbar/InlineToolbar.js'; export * from './Blocks/Blocks.js'; export * from './Toolbox/Toolbox.js'; export * from './Toolbar/Toolbar.js'; +export * from './BlockTunes/BlockTunesUI.js';