From 98fc3ec2e8f72c6493083b1e75bd90abb49084b2 Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Fri, 5 Jun 2026 16:44:04 +0200 Subject: [PATCH 1/7] feat: add block tunes --- .../src/components/BlockTunesManager.spec.ts | 95 +++++++++++++++++++ .../core/src/components/BlockTunesManager.ts | 47 +++++++++ packages/core/src/index.ts | 7 +- .../block-tunes/delete-block/index.spec.ts | 72 ++++++++++++++ .../block-tunes/delete-block/index.ts | 39 ++++++++ .../block-tunes/move-down/index.spec.ts | 86 +++++++++++++++++ .../internal/block-tunes/move-down/index.ts | 43 +++++++++ .../block-tunes/move-up/index.spec.ts | 82 ++++++++++++++++ .../internal/block-tunes/move-up/index.ts | 42 ++++++++ packages/core/src/tools/internal/index.ts | 3 + packages/sdk/src/entities/BlockTune.ts | 12 +++ .../events/core/BlockSelectedCoreEvent.ts | 37 ++++++++ .../EventBus/events/core/CoreEventType.ts | 7 +- .../entities/EventBus/events/core/index.ts | 1 + .../events/ui/BlockSelectedUIEvent.ts | 30 ++++++ .../src/entities/EventBus/events/ui/index.ts | 1 + .../src/tools/facades/BlockTuneFacade.spec.ts | 66 +++++++++++++ .../sdk/src/tools/facades/BlockTuneFacade.ts | 20 ++-- .../src/Blocks/events/BlockSelectedUIEvent.ts | 31 +----- packages/ui/src/Toolbar/Toolbar.const.ts | 1 + packages/ui/src/Toolbar/Toolbar.ts | 31 +++++- 21 files changed, 709 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/components/BlockTunesManager.spec.ts create mode 100644 packages/core/src/components/BlockTunesManager.ts create mode 100644 packages/core/src/tools/internal/block-tunes/delete-block/index.spec.ts create mode 100644 packages/core/src/tools/internal/block-tunes/delete-block/index.ts create mode 100644 packages/core/src/tools/internal/block-tunes/move-down/index.spec.ts create mode 100644 packages/core/src/tools/internal/block-tunes/move-down/index.ts create mode 100644 packages/core/src/tools/internal/block-tunes/move-up/index.spec.ts create mode 100644 packages/core/src/tools/internal/block-tunes/move-up/index.ts create mode 100644 packages/sdk/src/entities/EventBus/events/core/BlockSelectedCoreEvent.ts create mode 100644 packages/sdk/src/entities/EventBus/events/ui/BlockSelectedUIEvent.ts create mode 100644 packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts diff --git a/packages/core/src/components/BlockTunesManager.spec.ts b/packages/core/src/components/BlockTunesManager.spec.ts new file mode 100644 index 00000000..6b9af9b4 --- /dev/null +++ b/packages/core/src/components/BlockTunesManager.spec.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; +import type { CoreConfigValidated } from '@editorjs/sdk'; + +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(), + })), +})); + +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 eventBus = new EventBus(); + // @ts-expect-error — mocked instance + const toolsManager = new ToolsManager(); + + new BlockTunesManager( + { userId: 'user' } as unknown as CoreConfigValidated, + eventBus, + toolsManager, + mockApi as unknown as import('../api/index.js').EditorAPI + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIdByIndex.mockReturnValue(mockBlockId); + }); + + it('emits 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('does 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('calls facade.create() for each registered tune with blockId and api', () => { + blockSelectedListener({ detail: { index: 0, + block: {} } } as unknown as CustomEvent); + + expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi); + }); +}); diff --git a/packages/core/src/components/BlockTunesManager.ts b/packages/core/src/components/BlockTunesManager.ts new file mode 100644 index 00000000..51c651a3 --- /dev/null +++ b/packages/core/src/components/BlockTunesManager.ts @@ -0,0 +1,47 @@ +import 'reflect-metadata'; +import { inject, injectable } from 'inversify'; +import { + BlockSelectedCoreEvent, + BlockSelectedUIEvent, + CoreConfigValidated, + EventBus +} from '@editorjs/sdk'; +import { TOKENS } from '../tokens.js'; +import ToolsManager from '../tools/ToolsManager.js'; +import { EditorAPI } from '../api/index.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 { + constructor( + @inject(TOKENS.EditorConfig) _config: CoreConfigValidated, + eventBus: EventBus, + toolsManager: ToolsManager, + api: EditorAPI, + ) { + 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]) => [ + name, + facade.create({}, blockId, api), + ]) + ); + + eventBus.dispatchEvent(new BlockSelectedCoreEvent({ + index, + blockId, + availableBlockTunes, + })); + }); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0f12401a..c29a5efc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,7 +16,7 @@ import ToolsManager from './tools/ToolsManager.js'; import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, BlockTuneConstructor, ToolConstructable, EditorjsAdapterPluginConstructor } from '@editorjs/sdk'; import { EditorAPI } from './api/index.js'; import { generateId } from './utils/uid.js'; -import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool } from './tools/internal'; +import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool, MoveUpTune, DeleteBlockTune, MoveDownTune } from './tools/internal'; import { ShortcutsPlugin } from './plugins/ShortcutsPlugin.js'; import { DOMAdapters } from '@editorjs/dom-adapters'; import { BlocksManager } from './components/BlockManager.js'; @@ -24,6 +24,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 */ @@ -104,6 +105,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 +177,7 @@ export default class Core { this.#iocContainer.get(BlocksManager); this.#iocContainer.get(BlockRenderer); this.#iocContainer.get(UndoRedoManager); + this.#iocContainer.get(BlockTunesManager); this.#model.initializeDocument({ blocks }); diff --git a/packages/core/src/tools/internal/block-tunes/delete-block/index.spec.ts b/packages/core/src/tools/internal/block-tunes/delete-block/index.spec.ts new file mode 100644 index 00000000..80a81f43 --- /dev/null +++ b/packages/core/src/tools/internal/block-tunes/delete-block/index.spec.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + ToolType: { Tune: 'tune' }, +})); + +jest.unstable_mockModule('@codexteam/icons', () => ({ + IconCross: '', +})); + +let clickHandler: () => void; + +jest.unstable_mockModule('@editorjs/dom', () => ({ + make: jest.fn(() => ({ + addEventListener: jest.fn((event: string, handler: () => void) => { + if (event === 'click') { + clickHandler = handler; + } + }), + innerHTML: '', + title: '', + })), +})); + +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 import('@editorjs/model').BlockId; + + const tune = new DeleteBlockTune({ + api: mockApi as unknown as import('@editorjs/sdk').EditorAPI, + blockId: mockBlockId, + data: undefined, + config: {}, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIndexById.mockReturnValue(1); + }); + + it('render() returns an element', () => { + const element = tune.render(); + + expect(element).toBeDefined(); + }); + + it('click deletes the block at the resolved index', () => { + tune.render(); + clickHandler(); + + expect(mockApi.blocks.getIndexById).toHaveBeenCalledWith(String(mockBlockId)); + expect(mockApi.blocks.delete).toHaveBeenCalledWith({ block: 1 }); + }); + + it('resolves index at click-time, not construction-time', () => { + mockApi.blocks.getIndexById.mockReturnValue(4); + tune.render(); + clickHandler(); + + expect(mockApi.blocks.delete).toHaveBeenCalledWith({ block: 4 }); + }); +}); diff --git a/packages/core/src/tools/internal/block-tunes/delete-block/index.ts b/packages/core/src/tools/internal/block-tunes/delete-block/index.ts new file mode 100644 index 00000000..ccc73933 --- /dev/null +++ b/packages/core/src/tools/internal/block-tunes/delete-block/index.ts @@ -0,0 +1,39 @@ +import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import { make } from '@editorjs/dom'; +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 as const; + public static readonly name = 'deleteBlock'; + + #api: EditorAPI; + #blockId: BlockId; + + constructor({ api, blockId }: BlockTuneConstructorOptions) { + this.#api = api; + this.#blockId = blockId; + } + + public render(): HTMLElement { + const button = make('button') as HTMLButtonElement; + + button.innerHTML = IconCross; + button.title = 'Delete'; + + button.addEventListener('click', () => { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + + this.#api.blocks.delete({ block: index }); + }); + + return button; + } +} + +DeleteBlockTune satisfies BlockTuneConstructor; diff --git a/packages/core/src/tools/internal/block-tunes/move-down/index.spec.ts b/packages/core/src/tools/internal/block-tunes/move-down/index.spec.ts new file mode 100644 index 00000000..e731c6e9 --- /dev/null +++ b/packages/core/src/tools/internal/block-tunes/move-down/index.spec.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + ToolType: { Tune: 'tune' }, +})); + +jest.unstable_mockModule('@codexteam/icons', () => ({ + IconChevronDown: '', +})); + +let clickHandler: () => void; + +jest.unstable_mockModule('@editorjs/dom', () => ({ + make: jest.fn(() => ({ + addEventListener: jest.fn((event: string, handler: () => void) => { + if (event === 'click') { + clickHandler = handler; + } + }), + innerHTML: '', + title: '', + })), +})); + +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 import('@editorjs/model').BlockId; + + const tune = new MoveDownTune({ + api: mockApi as unknown as import('@editorjs/sdk').EditorAPI, + blockId: mockBlockId, + data: undefined, + config: {}, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIndexById.mockReturnValue(1); + mockApi.blocks.getBlocksCount.mockReturnValue(3); + }); + + it('render() returns an element', () => { + const element = tune.render(); + + expect(element).toBeDefined(); + }); + + it('click moves block down by one position', () => { + tune.render(); + clickHandler(); + + expect(mockApi.blocks.getIndexById).toHaveBeenCalledWith(String(mockBlockId)); + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 2, + fromIndex: 1 }); + }); + + it('click does nothing when block is already last', () => { + mockApi.blocks.getIndexById.mockReturnValue(2); + mockApi.blocks.getBlocksCount.mockReturnValue(3); + tune.render(); + clickHandler(); + + expect(mockApi.blocks.move).not.toHaveBeenCalled(); + }); + + it('resolves index at click-time, not construction-time', () => { + mockApi.blocks.getIndexById.mockReturnValue(0); + mockApi.blocks.getBlocksCount.mockReturnValue(5); + tune.render(); + clickHandler(); + + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 1, + fromIndex: 0 }); + }); +}); diff --git a/packages/core/src/tools/internal/block-tunes/move-down/index.ts b/packages/core/src/tools/internal/block-tunes/move-down/index.ts new file mode 100644 index 00000000..71717425 --- /dev/null +++ b/packages/core/src/tools/internal/block-tunes/move-down/index.ts @@ -0,0 +1,43 @@ +import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import { make } from '@editorjs/dom'; +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 as const; + public static readonly name = 'moveDown'; + + #api: EditorAPI; + #blockId: BlockId; + + constructor({ api, blockId }: BlockTuneConstructorOptions) { + this.#api = api; + this.#blockId = blockId; + } + + public render(): HTMLElement { + const button = make('button') as HTMLButtonElement; + + button.innerHTML = IconChevronDown; + button.title = 'Move down'; + + button.addEventListener('click', () => { + 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 }); + } + }); + + return button; + } +} + +MoveDownTune satisfies BlockTuneConstructor; diff --git a/packages/core/src/tools/internal/block-tunes/move-up/index.spec.ts b/packages/core/src/tools/internal/block-tunes/move-up/index.spec.ts new file mode 100644 index 00000000..6d3000a6 --- /dev/null +++ b/packages/core/src/tools/internal/block-tunes/move-up/index.spec.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ + +import { jest } from '@jest/globals'; + +jest.unstable_mockModule('@editorjs/sdk', () => ({ + ToolType: { Tune: 'tune' }, +})); + +jest.unstable_mockModule('@codexteam/icons', () => ({ + IconChevronUp: '', +})); + +let clickHandler: () => void; + +jest.unstable_mockModule('@editorjs/dom', () => ({ + make: jest.fn(() => ({ + addEventListener: jest.fn((event: string, handler: () => void) => { + if (event === 'click') { + clickHandler = handler; + } + }), + innerHTML: '', + title: '', + })), +})); + +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 import('@editorjs/model').BlockId; + + const tune = new MoveUpTune({ + api: mockApi as unknown as import('@editorjs/sdk').EditorAPI, + blockId: mockBlockId, + data: undefined, + config: {}, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockApi.blocks.getIndexById.mockReturnValue(2); + }); + + it('render() returns an element', () => { + const element = tune.render(); + + expect(element).toBeDefined(); + }); + + it('click moves block up by one position', () => { + tune.render(); + clickHandler(); + + expect(mockApi.blocks.getIndexById).toHaveBeenCalledWith(String(mockBlockId)); + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 1, + fromIndex: 2 }); + }); + + it('click does nothing when block is already first', () => { + mockApi.blocks.getIndexById.mockReturnValue(0); + tune.render(); + clickHandler(); + + expect(mockApi.blocks.move).not.toHaveBeenCalled(); + }); + + it('resolves index at click-time, not construction-time', () => { + mockApi.blocks.getIndexById.mockReturnValue(3); + tune.render(); + clickHandler(); + + expect(mockApi.blocks.move).toHaveBeenCalledWith({ toIndex: 2, + fromIndex: 3 }); + }); +}); diff --git a/packages/core/src/tools/internal/block-tunes/move-up/index.ts b/packages/core/src/tools/internal/block-tunes/move-up/index.ts new file mode 100644 index 00000000..5ec0e9a6 --- /dev/null +++ b/packages/core/src/tools/internal/block-tunes/move-up/index.ts @@ -0,0 +1,42 @@ +import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; +import { ToolType } from '@editorjs/sdk'; +import { make } from '@editorjs/dom'; +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 as const; + public static readonly name = 'moveUp'; + + #api: EditorAPI; + #blockId: BlockId; + + constructor({ api, blockId }: BlockTuneConstructorOptions) { + this.#api = api; + this.#blockId = blockId; + } + + public render(): HTMLElement { + const button = make('button') as HTMLButtonElement; + + button.innerHTML = IconChevronUp; + button.title = 'Move up'; + + button.addEventListener('click', () => { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + + if (index > 0) { + this.#api.blocks.move({ toIndex: index - 1, + fromIndex: index }); + } + }); + + return button; + } +} + +MoveUpTune satisfies BlockTuneConstructor; diff --git a/packages/core/src/tools/internal/index.ts b/packages/core/src/tools/internal/index.ts index 21b0d643..bd3f53a5 100644 --- a/packages/core/src/tools/internal/index.ts +++ b/packages/core/src/tools/internal/index.ts @@ -2,3 +2,6 @@ export * from './block-tools/paragraph/index.js'; export * from './inline-tools/bold/index.js'; export * from './inline-tools/italic/index.js'; export * from './inline-tools/link/index.js'; +export * from './block-tunes/move-up/index.js'; +export * from './block-tunes/delete-block/index.js'; +export * from './block-tunes/move-down/index.js'; diff --git a/packages/sdk/src/entities/BlockTune.ts b/packages/sdk/src/entities/BlockTune.ts index 66ebbbf4..17e62601 100644 --- a/packages/sdk/src/entities/BlockTune.ts +++ b/packages/sdk/src/entities/BlockTune.ts @@ -5,6 +5,8 @@ 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'; /** * Options available on **Block Tunes** (`static options` or `use()` overrides). @@ -44,6 +46,16 @@ 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; } /** 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/tools/facades/BlockTuneFacade.spec.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts new file mode 100644 index 00000000..838b31b4 --- /dev/null +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts @@ -0,0 +1,66 @@ +/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ + +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 { BlockId } from '@editorjs/model'; + +const mockBlockId = 'test-block-id' as unknown as BlockId; +const mockApi = {} as EditorAPI; + +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 import('@editorjs/editorjs').API, + isDefault: false, + defaultPlaceholder: false, + }); + + return { facade, + constructorSpy }; +} + +describe('BlockTuneFacade', () => { + describe('create()', () => { + it('passes data, blockId and api to the tune constructor', () => { + const { facade, constructorSpy } = createTuneFacade(); + const tuneData = { foo: 'bar' }; + + facade.create(tuneData, mockBlockId, mockApi); + + expect(constructorSpy).toHaveBeenCalledWith(expect.objectContaining({ + data: tuneData, + blockId: mockBlockId, + api: mockApi, + })); + }); + + it('returns the tune instance created by the constructor', () => { + const { facade } = createTuneFacade(); + + const instance = facade.create({}, mockBlockId, mockApi); + + 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..358b7c1c 100644 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.ts +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.ts @@ -1,12 +1,11 @@ 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'; /** - * Stub class for BlockTunes - * @todo Implement + * Facade for BlockTune tools */ export class BlockTuneFacade extends BaseToolFacade { /** @@ -20,17 +19,18 @@ 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 */ - public create(data: BlockTuneData, _block: BlockAPI): IBlockTune { + public create(data: BlockTuneData, blockId: BlockId, api: EditorAPI): IBlockTune { return new this.constructable({ - // api: this.api, config: this.config, - // block, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data, + api, + blockId, }); } } 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..fef785a6 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'), + tuneButtons: className('tune-buttons'), }; diff --git a/packages/ui/src/Toolbar/Toolbar.ts b/packages/ui/src/Toolbar/Toolbar.ts index 50e2f81d..5fd9f32c 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -1,5 +1,5 @@ -import type { EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; -import { UiComponentType } from '@editorjs/sdk'; +import type { BlockSelectedCoreEvent, BlockTune, EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; +import { CoreEventType, UiComponentType } from '@editorjs/sdk'; import { make } from '@editorjs/dom'; import { css } from './Toolbar.const.js'; import type { ToolboxRenderedUIEvent } from '../Toolbox/events/index.js'; @@ -27,6 +27,11 @@ interface ToolbarNodes { * Plus button to open Toolbox popover */ plusButton: HTMLButtonElement; + + /** + * Container for block tune action buttons + */ + tuneButtons: HTMLDivElement; } /** @@ -54,6 +59,7 @@ export class ToolbarUI implements EditorjsPlugin { plusButton: make('button', Style[css.plusButton], { innerHTML: IconPlus, }) as HTMLButtonElement, + tuneButtons: make('div', Style[css.tuneButtons]) as HTMLDivElement, }; /** @@ -85,6 +91,10 @@ export class ToolbarUI implements EditorjsPlugin { this.moveTo(event.detail.block); }); + + this.#eventBus.addEventListener(`core:${CoreEventType.BlockSelected}`, (event: BlockSelectedCoreEvent) => { + this.#renderTunes(event.detail.availableBlockTunes); + }); } /** @@ -117,6 +127,7 @@ export class ToolbarUI implements EditorjsPlugin { #render(): void { this.#nodes.holder.appendChild(this.#nodes.actions); this.#nodes.actions.appendChild(this.#nodes.plusButton); + this.#nodes.holder.appendChild(this.#nodes.tuneButtons); this.#nodes.plusButton.addEventListener('click', () => { this.#openToolbox(); @@ -127,6 +138,22 @@ export class ToolbarUI implements EditorjsPlugin { })); } + /** + * Renders tune action buttons into the toolbar + * @param tunes - map of tune name to tune instance + */ + #renderTunes(tunes: Map): void { + this.#nodes.tuneButtons.innerHTML = ''; + + tunes.forEach((tune) => { + const element = tune.render(); + + if (element instanceof HTMLElement) { + this.#nodes.tuneButtons.appendChild(element); + } + }); + } + /** * Subscribes to Toolbox event */ From 9c08b986dc3ef5d28e095b535a46604f06d74353 Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Fri, 5 Jun 2026 16:53:25 +0200 Subject: [PATCH 2/7] feat: move from tools to tunes --- packages/core/src/index.ts | 3 ++- packages/core/src/tools/internal/index.ts | 3 --- .../block-tunes => tunes/internal}/delete-block/index.spec.ts | 0 .../block-tunes => tunes/internal}/delete-block/index.ts | 0 packages/core/src/tunes/internal/index.ts | 3 +++ .../block-tunes => tunes/internal}/move-down/index.spec.ts | 0 .../internal/block-tunes => tunes/internal}/move-down/index.ts | 0 .../block-tunes => tunes/internal}/move-up/index.spec.ts | 0 .../internal/block-tunes => tunes/internal}/move-up/index.ts | 0 9 files changed, 5 insertions(+), 4 deletions(-) rename packages/core/src/{tools/internal/block-tunes => tunes/internal}/delete-block/index.spec.ts (100%) rename packages/core/src/{tools/internal/block-tunes => tunes/internal}/delete-block/index.ts (100%) create mode 100644 packages/core/src/tunes/internal/index.ts rename packages/core/src/{tools/internal/block-tunes => tunes/internal}/move-down/index.spec.ts (100%) rename packages/core/src/{tools/internal/block-tunes => tunes/internal}/move-down/index.ts (100%) rename packages/core/src/{tools/internal/block-tunes => tunes/internal}/move-up/index.spec.ts (100%) rename packages/core/src/{tools/internal/block-tunes => tunes/internal}/move-up/index.ts (100%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c29a5efc..3e06b3a6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,7 +16,8 @@ import ToolsManager from './tools/ToolsManager.js'; import type { CoreConfigValidated, CoreConfig, EditorjsPluginConstructor, BlockTuneConstructor, ToolConstructable, EditorjsAdapterPluginConstructor } from '@editorjs/sdk'; import { EditorAPI } from './api/index.js'; import { generateId } from './utils/uid.js'; -import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool, MoveUpTune, DeleteBlockTune, MoveDownTune } from './tools/internal'; +import { Paragraph, BoldInlineTool, LinkInlineTool, ItalicInlineTool } from './tools/internal'; +import { MoveUpTune, DeleteBlockTune, MoveDownTune } from './tunes/internal/index.js'; import { ShortcutsPlugin } from './plugins/ShortcutsPlugin.js'; import { DOMAdapters } from '@editorjs/dom-adapters'; import { BlocksManager } from './components/BlockManager.js'; diff --git a/packages/core/src/tools/internal/index.ts b/packages/core/src/tools/internal/index.ts index bd3f53a5..21b0d643 100644 --- a/packages/core/src/tools/internal/index.ts +++ b/packages/core/src/tools/internal/index.ts @@ -2,6 +2,3 @@ export * from './block-tools/paragraph/index.js'; export * from './inline-tools/bold/index.js'; export * from './inline-tools/italic/index.js'; export * from './inline-tools/link/index.js'; -export * from './block-tunes/move-up/index.js'; -export * from './block-tunes/delete-block/index.js'; -export * from './block-tunes/move-down/index.js'; diff --git a/packages/core/src/tools/internal/block-tunes/delete-block/index.spec.ts b/packages/core/src/tunes/internal/delete-block/index.spec.ts similarity index 100% rename from packages/core/src/tools/internal/block-tunes/delete-block/index.spec.ts rename to packages/core/src/tunes/internal/delete-block/index.spec.ts diff --git a/packages/core/src/tools/internal/block-tunes/delete-block/index.ts b/packages/core/src/tunes/internal/delete-block/index.ts similarity index 100% rename from packages/core/src/tools/internal/block-tunes/delete-block/index.ts rename to packages/core/src/tunes/internal/delete-block/index.ts 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/tools/internal/block-tunes/move-down/index.spec.ts b/packages/core/src/tunes/internal/move-down/index.spec.ts similarity index 100% rename from packages/core/src/tools/internal/block-tunes/move-down/index.spec.ts rename to packages/core/src/tunes/internal/move-down/index.spec.ts diff --git a/packages/core/src/tools/internal/block-tunes/move-down/index.ts b/packages/core/src/tunes/internal/move-down/index.ts similarity index 100% rename from packages/core/src/tools/internal/block-tunes/move-down/index.ts rename to packages/core/src/tunes/internal/move-down/index.ts diff --git a/packages/core/src/tools/internal/block-tunes/move-up/index.spec.ts b/packages/core/src/tunes/internal/move-up/index.spec.ts similarity index 100% rename from packages/core/src/tools/internal/block-tunes/move-up/index.spec.ts rename to packages/core/src/tunes/internal/move-up/index.spec.ts diff --git a/packages/core/src/tools/internal/block-tunes/move-up/index.ts b/packages/core/src/tunes/internal/move-up/index.ts similarity index 100% rename from packages/core/src/tools/internal/block-tunes/move-up/index.ts rename to packages/core/src/tunes/internal/move-up/index.ts From 60de15ac57e9c57a1b0039f6da9fabdfd23448de Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Fri, 5 Jun 2026 17:40:41 +0200 Subject: [PATCH 3/7] feat: add tune settings menu --- .../src/components/BlockTunesManager.spec.ts | 6 +- .../tunes/internal/delete-block/index.spec.ts | 31 ++--- .../src/tunes/internal/delete-block/index.ts | 19 +-- .../tunes/internal/move-down/index.spec.ts | 36 ++---- .../src/tunes/internal/move-down/index.ts | 27 ++--- .../src/tunes/internal/move-up/index.spec.ts | 36 ++---- .../core/src/tunes/internal/move-up/index.ts | 25 ++-- packages/playground/src/App.vue | 3 +- packages/sdk/src/entities/BlockTune.ts | 23 +++- packages/sdk/src/entities/EntityType.ts | 7 +- .../sdk/src/tools/facades/BaseToolFacade.ts | 5 +- packages/ui/src/BlockTunes/BlockTunesUI.ts | 111 ++++++++++++++++++ .../BlockTunes/events/BlockTunesBaseEvent.ts | 10 ++ .../events/BlockTunesClosedUIEvent.ts | 10 ++ .../events/BlockTunesOpenUIEvent.ts | 10 ++ .../events/BlockTunesOpenedUIEvent.ts | 10 ++ .../events/BlockTunesRenderedUIEvent.ts | 11 ++ packages/ui/src/BlockTunes/events/index.ts | 5 + packages/ui/src/Toolbar/Toolbar.const.ts | 2 +- packages/ui/src/Toolbar/Toolbar.module.pcss | 8 ++ packages/ui/src/Toolbar/Toolbar.ts | 59 +++++----- packages/ui/src/index.ts | 1 + 22 files changed, 291 insertions(+), 164 deletions(-) create mode 100644 packages/ui/src/BlockTunes/BlockTunesUI.ts create mode 100644 packages/ui/src/BlockTunes/events/BlockTunesBaseEvent.ts create mode 100644 packages/ui/src/BlockTunes/events/BlockTunesClosedUIEvent.ts create mode 100644 packages/ui/src/BlockTunes/events/BlockTunesOpenUIEvent.ts create mode 100644 packages/ui/src/BlockTunes/events/BlockTunesOpenedUIEvent.ts create mode 100644 packages/ui/src/BlockTunes/events/BlockTunesRenderedUIEvent.ts create mode 100644 packages/ui/src/BlockTunes/events/index.ts diff --git a/packages/core/src/components/BlockTunesManager.spec.ts b/packages/core/src/components/BlockTunesManager.spec.ts index 6b9af9b4..8e68bde3 100644 --- a/packages/core/src/components/BlockTunesManager.spec.ts +++ b/packages/core/src/components/BlockTunesManager.spec.ts @@ -64,7 +64,7 @@ describe('BlockTunesManager', () => { mockApi.blocks.getIdByIndex.mockReturnValue(mockBlockId); }); - it('emits BlockSelectedCoreEvent with tune instances when a block is selected', () => { + it('should emit BlockSelectedCoreEvent with tune instances when a block is selected', () => { blockSelectedListener({ detail: { index: 2, block: {} } } as unknown as CustomEvent); @@ -77,7 +77,7 @@ describe('BlockTunesManager', () => { expect(eventBus.dispatchEvent).toHaveBeenCalled(); }); - it('does not emit when blockId is undefined', () => { + it('should not emit when blockId is undefined', () => { mockApi.blocks.getIdByIndex.mockReturnValue(undefined as unknown as string); blockSelectedListener({ detail: { index: 99, @@ -86,7 +86,7 @@ describe('BlockTunesManager', () => { expect(eventBus.dispatchEvent).not.toHaveBeenCalled(); }); - it('calls facade.create() for each registered tune with blockId and api', () => { + it('should call facade.create() for each registered tune with blockId and api', () => { blockSelectedListener({ detail: { index: 0, block: {} } } as unknown as CustomEvent); diff --git a/packages/core/src/tunes/internal/delete-block/index.spec.ts b/packages/core/src/tunes/internal/delete-block/index.spec.ts index 80a81f43..43a4ed08 100644 --- a/packages/core/src/tunes/internal/delete-block/index.spec.ts +++ b/packages/core/src/tunes/internal/delete-block/index.spec.ts @@ -10,20 +10,6 @@ jest.unstable_mockModule('@codexteam/icons', () => ({ IconCross: '', })); -let clickHandler: () => void; - -jest.unstable_mockModule('@editorjs/dom', () => ({ - make: jest.fn(() => ({ - addEventListener: jest.fn((event: string, handler: () => void) => { - if (event === 'click') { - clickHandler = handler; - } - }), - innerHTML: '', - title: '', - })), -})); - const { DeleteBlockTune } = await import('./index.js'); describe('DeleteBlockTune', () => { @@ -48,24 +34,21 @@ describe('DeleteBlockTune', () => { mockApi.blocks.getIndexById.mockReturnValue(1); }); - it('render() returns an element', () => { - const element = tune.render(); - - expect(element).toBeDefined(); + it('should have title and icon', () => { + expect(tune.title).toBe('Delete'); + expect(tune.icon).toBeDefined(); }); - it('click deletes the block at the resolved index', () => { - tune.render(); - clickHandler(); + 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('resolves index at click-time, not construction-time', () => { + it('should resolve index at activate-time, not construction-time', () => { mockApi.blocks.getIndexById.mockReturnValue(4); - tune.render(); - clickHandler(); + 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 index ccc73933..eef808bf 100644 --- a/packages/core/src/tunes/internal/delete-block/index.ts +++ b/packages/core/src/tunes/internal/delete-block/index.ts @@ -1,6 +1,5 @@ import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk'; -import { make } from '@editorjs/dom'; import { IconCross } from '@codexteam/icons'; import type { EditorAPI } from '@editorjs/sdk'; import type { BlockId } from '@editorjs/model'; @@ -12,6 +11,9 @@ export class DeleteBlockTune implements BlockTune { public static readonly type = ToolType.Tune as const; public static readonly name = 'deleteBlock'; + public readonly title = 'Delete'; + public readonly icon = IconCross; + #api: EditorAPI; #blockId: BlockId; @@ -20,19 +22,10 @@ export class DeleteBlockTune implements BlockTune { this.#blockId = blockId; } - public render(): HTMLElement { - const button = make('button') as HTMLButtonElement; - - button.innerHTML = IconCross; - button.title = 'Delete'; - - button.addEventListener('click', () => { - const index = this.#api.blocks.getIndexById(String(this.#blockId)); - - this.#api.blocks.delete({ block: index }); - }); + public activate(): void { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); - return button; + this.#api.blocks.delete({ block: index }); } } diff --git a/packages/core/src/tunes/internal/move-down/index.spec.ts b/packages/core/src/tunes/internal/move-down/index.spec.ts index e731c6e9..19c6c0c9 100644 --- a/packages/core/src/tunes/internal/move-down/index.spec.ts +++ b/packages/core/src/tunes/internal/move-down/index.spec.ts @@ -10,20 +10,6 @@ jest.unstable_mockModule('@codexteam/icons', () => ({ IconChevronDown: '', })); -let clickHandler: () => void; - -jest.unstable_mockModule('@editorjs/dom', () => ({ - make: jest.fn(() => ({ - addEventListener: jest.fn((event: string, handler: () => void) => { - if (event === 'click') { - clickHandler = handler; - } - }), - innerHTML: '', - title: '', - })), -})); - const { MoveDownTune } = await import('./index.js'); describe('MoveDownTune', () => { @@ -50,35 +36,31 @@ describe('MoveDownTune', () => { mockApi.blocks.getBlocksCount.mockReturnValue(3); }); - it('render() returns an element', () => { - const element = tune.render(); - - expect(element).toBeDefined(); + it('should have title and icon', () => { + expect(tune.title).toBe('Move down'); + expect(tune.icon).toBeDefined(); }); - it('click moves block down by one position', () => { - tune.render(); - clickHandler(); + 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('click does nothing when block is already last', () => { + it('should do nothing on activate() when block is already last', () => { mockApi.blocks.getIndexById.mockReturnValue(2); mockApi.blocks.getBlocksCount.mockReturnValue(3); - tune.render(); - clickHandler(); + tune.activate(); expect(mockApi.blocks.move).not.toHaveBeenCalled(); }); - it('resolves index at click-time, not construction-time', () => { + it('should resolve index at activate-time, not construction-time', () => { mockApi.blocks.getIndexById.mockReturnValue(0); mockApi.blocks.getBlocksCount.mockReturnValue(5); - tune.render(); - clickHandler(); + 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 index 71717425..0bd9e0f7 100644 --- a/packages/core/src/tunes/internal/move-down/index.ts +++ b/packages/core/src/tunes/internal/move-down/index.ts @@ -1,6 +1,5 @@ import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk'; -import { make } from '@editorjs/dom'; import { IconChevronDown } from '@codexteam/icons'; import type { EditorAPI } from '@editorjs/sdk'; import type { BlockId } from '@editorjs/model'; @@ -12,6 +11,9 @@ export class MoveDownTune implements BlockTune { public static readonly type = ToolType.Tune as const; public static readonly name = 'moveDown'; + public readonly title = 'Move down'; + public readonly icon = IconChevronDown; + #api: EditorAPI; #blockId: BlockId; @@ -20,23 +22,14 @@ export class MoveDownTune implements BlockTune { this.#blockId = blockId; } - public render(): HTMLElement { - const button = make('button') as HTMLButtonElement; - - button.innerHTML = IconChevronDown; - button.title = 'Move down'; - - button.addEventListener('click', () => { - 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 }); - } - }); + public activate(): void { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + const count = this.#api.blocks.getBlocksCount(); - return button; + if (index < count - 1) { + this.#api.blocks.move({ toIndex: index + 1, + fromIndex: index }); + } } } diff --git a/packages/core/src/tunes/internal/move-up/index.spec.ts b/packages/core/src/tunes/internal/move-up/index.spec.ts index 6d3000a6..091a9930 100644 --- a/packages/core/src/tunes/internal/move-up/index.spec.ts +++ b/packages/core/src/tunes/internal/move-up/index.spec.ts @@ -10,20 +10,6 @@ jest.unstable_mockModule('@codexteam/icons', () => ({ IconChevronUp: '', })); -let clickHandler: () => void; - -jest.unstable_mockModule('@editorjs/dom', () => ({ - make: jest.fn(() => ({ - addEventListener: jest.fn((event: string, handler: () => void) => { - if (event === 'click') { - clickHandler = handler; - } - }), - innerHTML: '', - title: '', - })), -})); - const { MoveUpTune } = await import('./index.js'); describe('MoveUpTune', () => { @@ -48,33 +34,29 @@ describe('MoveUpTune', () => { mockApi.blocks.getIndexById.mockReturnValue(2); }); - it('render() returns an element', () => { - const element = tune.render(); - - expect(element).toBeDefined(); + it('should have title and icon', () => { + expect(tune.title).toBe('Move up'); + expect(tune.icon).toBeDefined(); }); - it('click moves block up by one position', () => { - tune.render(); - clickHandler(); + 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('click does nothing when block is already first', () => { + it('should do nothing on activate() when block is already first', () => { mockApi.blocks.getIndexById.mockReturnValue(0); - tune.render(); - clickHandler(); + tune.activate(); expect(mockApi.blocks.move).not.toHaveBeenCalled(); }); - it('resolves index at click-time, not construction-time', () => { + it('should resolve index at activate-time, not construction-time', () => { mockApi.blocks.getIndexById.mockReturnValue(3); - tune.render(); - clickHandler(); + 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 index 5ec0e9a6..6f310453 100644 --- a/packages/core/src/tunes/internal/move-up/index.ts +++ b/packages/core/src/tunes/internal/move-up/index.ts @@ -1,6 +1,5 @@ import type { BlockTune, BlockTuneConstructor, BlockTuneConstructorOptions } from '@editorjs/sdk'; import { ToolType } from '@editorjs/sdk'; -import { make } from '@editorjs/dom'; import { IconChevronUp } from '@codexteam/icons'; import type { EditorAPI } from '@editorjs/sdk'; import type { BlockId } from '@editorjs/model'; @@ -12,6 +11,9 @@ export class MoveUpTune implements BlockTune { public static readonly type = ToolType.Tune as const; public static readonly name = 'moveUp'; + public readonly title = 'Move up'; + public readonly icon = IconChevronUp; + #api: EditorAPI; #blockId: BlockId; @@ -20,22 +22,13 @@ export class MoveUpTune implements BlockTune { this.#blockId = blockId; } - public render(): HTMLElement { - const button = make('button') as HTMLButtonElement; - - button.innerHTML = IconChevronUp; - button.title = 'Move up'; - - button.addEventListener('click', () => { - const index = this.#api.blocks.getIndexById(String(this.#blockId)); - - if (index > 0) { - this.#api.blocks.move({ toIndex: index - 1, - fromIndex: index }); - } - }); + public activate(): void { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); - return button; + if (index > 0) { + this.#api.blocks.move({ toIndex: index - 1, + fromIndex: index }); + } } } 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/entities/BlockTune.ts b/packages/sdk/src/entities/BlockTune.ts index 17e62601..b50a9264 100644 --- a/packages/sdk/src/entities/BlockTune.ts +++ b/packages/sdk/src/entities/BlockTune.ts @@ -60,7 +60,6 @@ export interface BlockTuneConstructorOptions< /** * Block Tune interface for version 3 - * @todo describe the interface when the adapter implementation is done */ export type BlockTune< /** @@ -77,7 +76,27 @@ 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; +}; /** * Block Tune constructor class 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/tools/facades/BaseToolFacade.ts b/packages/sdk/src/tools/facades/BaseToolFacade.ts index 043e743b..9c2f836c 100644 --- a/packages/sdk/src/tools/facades/BaseToolFacade.ts +++ b/packages/sdk/src/tools/facades/BaseToolFacade.ts @@ -10,7 +10,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'; @@ -81,7 +82,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/ui/src/BlockTunes/BlockTunesUI.ts b/packages/ui/src/BlockTunes/BlockTunesUI.ts new file mode 100644 index 00000000..1eac9b3a --- /dev/null +++ b/packages/ui/src/BlockTunes/BlockTunesUI.ts @@ -0,0 +1,111 @@ +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 } 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: [], + }); + + 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, + 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/Toolbar/Toolbar.const.ts b/packages/ui/src/Toolbar/Toolbar.const.ts index fef785a6..658273ec 100644 --- a/packages/ui/src/Toolbar/Toolbar.const.ts +++ b/packages/ui/src/Toolbar/Toolbar.const.ts @@ -6,5 +6,5 @@ export const css = { toolbar: className(), actions: className('actions'), plusButton: className('plus-button'), - tuneButtons: className('tune-buttons'), + 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 5fd9f32c..23b208fa 100644 --- a/packages/ui/src/Toolbar/Toolbar.ts +++ b/packages/ui/src/Toolbar/Toolbar.ts @@ -1,13 +1,15 @@ -import type { BlockSelectedCoreEvent, BlockTune, EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; -import { CoreEventType, UiComponentType } from '@editorjs/sdk'; +import type { EditorAPI, EditorjsPlugin, EditorjsPluginParams, EventBus } from '@editorjs/sdk'; +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 @@ -29,9 +31,9 @@ interface ToolbarNodes { plusButton: HTMLButtonElement; /** - * Container for block tune action buttons + * Settings button to open Block Tunes popover */ - tuneButtons: HTMLDivElement; + settingsButton: HTMLButtonElement; } /** @@ -59,7 +61,9 @@ export class ToolbarUI implements EditorjsPlugin { plusButton: make('button', Style[css.plusButton], { innerHTML: IconPlus, }) as HTMLButtonElement, - tuneButtons: make('div', Style[css.tuneButtons]) as HTMLDivElement, + settingsButton: make('button', Style[css.settingsButton], { + innerHTML: IconMenuSmall, + }) as HTMLButtonElement, }; /** @@ -84,7 +88,7 @@ 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; } @@ -92,8 +96,8 @@ export class ToolbarUI implements EditorjsPlugin { this.moveTo(event.detail.block); }); - this.#eventBus.addEventListener(`core:${CoreEventType.BlockSelected}`, (event: BlockSelectedCoreEvent) => { - this.#renderTunes(event.detail.availableBlockTunes); + this.#eventBus.addEventListener('ui:block-tunes:rendered', (event: BlockTunesRenderedUIEvent) => { + this.#nodes.actions.appendChild(event.detail.blockTunes); }); } @@ -127,46 +131,34 @@ export class ToolbarUI implements EditorjsPlugin { #render(): void { this.#nodes.holder.appendChild(this.#nodes.actions); this.#nodes.actions.appendChild(this.#nodes.plusButton); - this.#nodes.holder.appendChild(this.#nodes.tuneButtons); + 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, })); } - /** - * Renders tune action buttons into the toolbar - * @param tunes - map of tune name to tune instance - */ - #renderTunes(tunes: Map): void { - this.#nodes.tuneButtons.innerHTML = ''; - - tunes.forEach((tune) => { - const element = tune.render(); - - if (element instanceof HTMLElement) { - this.#nodes.tuneButtons.appendChild(element); - } - }); - } - /** * 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; }); } @@ -175,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'; From d3f4701247a65fe71ed6d8a4450fea4adfb595cb Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Fri, 5 Jun 2026 18:09:39 +0200 Subject: [PATCH 4/7] feat: disable tune if needed, change from div to button element --- .../src/tunes/internal/move-down/index.spec.ts | 14 ++++++++++++++ .../core/src/tunes/internal/move-down/index.ts | 6 ++++++ .../src/tunes/internal/move-up/index.spec.ts | 12 ++++++++++++ .../core/src/tunes/internal/move-up/index.ts | 4 ++++ packages/sdk/src/entities/BlockTune.ts | 6 ++++++ packages/ui/src/BlockTunes/BlockTunesUI.ts | 18 ++++++++++++------ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/core/src/tunes/internal/move-down/index.spec.ts b/packages/core/src/tunes/internal/move-down/index.spec.ts index 19c6c0c9..94112dc7 100644 --- a/packages/core/src/tunes/internal/move-down/index.spec.ts +++ b/packages/core/src/tunes/internal/move-down/index.spec.ts @@ -49,6 +49,20 @@ describe('MoveDownTune', () => { 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); diff --git a/packages/core/src/tunes/internal/move-down/index.ts b/packages/core/src/tunes/internal/move-down/index.ts index 0bd9e0f7..40cbb29f 100644 --- a/packages/core/src/tunes/internal/move-down/index.ts +++ b/packages/core/src/tunes/internal/move-down/index.ts @@ -22,6 +22,12 @@ export class MoveDownTune implements BlockTune { this.#blockId = blockId; } + public isDisabled(): boolean { + const index = this.#api.blocks.getIndexById(String(this.#blockId)); + + return index === this.#api.blocks.getBlocksCount() - 1; + } + public activate(): void { const index = this.#api.blocks.getIndexById(String(this.#blockId)); const count = this.#api.blocks.getBlocksCount(); diff --git a/packages/core/src/tunes/internal/move-up/index.spec.ts b/packages/core/src/tunes/internal/move-up/index.spec.ts index 091a9930..b8adc261 100644 --- a/packages/core/src/tunes/internal/move-up/index.spec.ts +++ b/packages/core/src/tunes/internal/move-up/index.spec.ts @@ -47,6 +47,18 @@ describe('MoveUpTune', () => { 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(); diff --git a/packages/core/src/tunes/internal/move-up/index.ts b/packages/core/src/tunes/internal/move-up/index.ts index 6f310453..a85df089 100644 --- a/packages/core/src/tunes/internal/move-up/index.ts +++ b/packages/core/src/tunes/internal/move-up/index.ts @@ -22,6 +22,10 @@ export class MoveUpTune implements BlockTune { this.#blockId = blockId; } + public isDisabled(): boolean { + return this.#api.blocks.getIndexById(String(this.#blockId)) === 0; + } + public activate(): void { const index = this.#api.blocks.getIndexById(String(this.#blockId)); diff --git a/packages/sdk/src/entities/BlockTune.ts b/packages/sdk/src/entities/BlockTune.ts index b50a9264..b894806a 100644 --- a/packages/sdk/src/entities/BlockTune.ts +++ b/packages/sdk/src/entities/BlockTune.ts @@ -96,6 +96,12 @@ export type BlockTune< * 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; }; /** diff --git a/packages/ui/src/BlockTunes/BlockTunesUI.ts b/packages/ui/src/BlockTunes/BlockTunesUI.ts index 1eac9b3a..3f6db623 100644 --- a/packages/ui/src/BlockTunes/BlockTunesUI.ts +++ b/packages/ui/src/BlockTunes/BlockTunesUI.ts @@ -9,7 +9,7 @@ import type { EventBus } from '@editorjs/sdk'; import { CoreEventType, UiComponentType } from '@editorjs/sdk'; -import { PopoverDesktop, PopoverEvent } from '@editorjs/ui-kit'; +import { PopoverDesktop, PopoverEvent, PopoverItemType } from '@editorjs/ui-kit'; import { BlockTunesClosedUIEvent, BlockTunesOpenedUIEvent, @@ -42,11 +42,16 @@ export class BlockTunesUI implements EditorjsPlugin { this.#eventBus = eventBus; this.#editorConfig = config; - this.#popover = new PopoverDesktop({ - scopeElement: this.#editorConfig.holder, - searchable: false, - items: [], - }); + 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({})); @@ -89,6 +94,7 @@ export class BlockTunesUI implements EditorjsPlugin { title: tune.title, icon: tune.icon, closeOnActivate: true, + isDisabled: tune.isDisabled?.(), onActivate: () => { tune.activate?.(); }, From d3f18ee212cac688c91167b6dc044e6e0fc226f1 Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Wed, 24 Jun 2026 22:54:07 +0200 Subject: [PATCH 5/7] refactor: add block tune adapter, like block tool adapter --- packages/core/src/api/BlocksAPI.spec.ts | 98 ++++++++++ packages/core/src/api/BlocksAPI.ts | 24 +++ .../core/src/components/BlockRenderer.spec.ts | 4 + packages/core/src/components/BlockRenderer.ts | 12 +- .../src/components/BlockTunesManager.spec.ts | 43 +++- .../core/src/components/BlockTunesManager.ts | 19 +- packages/core/src/index.ts | 2 +- packages/dom-adapters/jest.config.ts | 3 + .../src/BlockTuneAdapter/index.spec.ts | 184 ++++++++++++++++++ .../src/BlockTuneAdapter/index.ts | 30 +++ packages/dom-adapters/src/index.ts | 56 ++++++ packages/sdk/src/api/BlocksAPI.ts | 30 +++ packages/sdk/src/entities/BlockTune.ts | 6 + packages/sdk/src/entities/BlockTuneAdapter.ts | 129 ++++++++++++ .../sdk/src/entities/EditorjsAdapterPlugin.ts | 23 +++ .../events/adapter/AdapterEventType.ts | 7 +- .../events/adapter/TuneDataChanged.ts | 38 ++++ .../entities/EventBus/events/adapter/index.ts | 2 + packages/sdk/src/entities/index.ts | 1 + .../src/tools/facades/BlockTuneFacade.spec.ts | 11 +- .../sdk/src/tools/facades/BlockTuneFacade.ts | 5 +- 21 files changed, 706 insertions(+), 21 deletions(-) create mode 100644 packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts create mode 100644 packages/dom-adapters/src/BlockTuneAdapter/index.ts create mode 100644 packages/sdk/src/entities/BlockTuneAdapter.ts create mode 100644 packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index 42499fad..bbf312eb 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,101 @@ 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 b61d8893..529c7935 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -7,7 +7,9 @@ import { BlocksAPI as BlocksApiInterface } from '@editorjs/sdk'; import { BlockId, BlockIndexOrId, + BlockTuneName, createBlockId, + createBlockTuneName, createDataKey, EditorDocumentSerialized, EditorJSModel, @@ -208,4 +210,26 @@ export class BlocksAPI implements BlocksApiInterface { public split({ block, key, offset, userId }: Parameters[0]): void { this.#blocksManager.splitBlock(block as BlockIndexOrId, createDataKey(key), offset, userId); } + + /** + * 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 - name of the tune + */ + 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 - name of the tune + * @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) as BlockTuneName, 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 index 8e68bde3..bad0f76e 100644 --- a/packages/core/src/components/BlockTunesManager.spec.ts +++ b/packages/core/src/components/BlockTunesManager.spec.ts @@ -1,7 +1,6 @@ /* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ import { jest } from '@jest/globals'; -import type { CoreConfigValidated } from '@editorjs/sdk'; let blockSelectedListener: (event: CustomEvent) => void; @@ -19,6 +18,7 @@ jest.unstable_mockModule('@editorjs/sdk', () => ({ }), dispatchEvent: jest.fn(), })), + EditorJSAdapterPlugin: jest.fn(), })); const fakeFacade = { create: jest.fn(() => ({ render: jest.fn() })) }; @@ -48,20 +48,31 @@ describe('BlockTunesManager', () => { }, }; + 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( - { userId: 'user' } as unknown as CoreConfigValidated, eventBus, toolsManager, - mockApi as unknown as import('../api/index.js').EditorAPI + mockApi as unknown as import('../api/index.js').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', () => { @@ -86,10 +97,32 @@ describe('BlockTunesManager', () => { expect(eventBus.dispatchEvent).not.toHaveBeenCalled(); }); - it('should call facade.create() for each registered tune with blockId and api', () => { + 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(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi); + 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 index 51c651a3..87c34977 100644 --- a/packages/core/src/components/BlockTunesManager.ts +++ b/packages/core/src/components/BlockTunesManager.ts @@ -3,12 +3,12 @@ import { inject, injectable } from 'inversify'; import { BlockSelectedCoreEvent, BlockSelectedUIEvent, - CoreConfigValidated, + EditorJSAdapterPlugin, EventBus } from '@editorjs/sdk'; -import { TOKENS } from '../tokens.js'; 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 @@ -17,10 +17,10 @@ import { EditorAPI } from '../api/index.js'; @injectable() export class BlockTunesManager { constructor( - @inject(TOKENS.EditorConfig) _config: CoreConfigValidated, eventBus: EventBus, toolsManager: ToolsManager, api: EditorAPI, + @inject(TOKENS.Adapter) adapter: EditorJSAdapterPlugin ) { eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => { const { index } = event.detail; @@ -31,10 +31,15 @@ export class BlockTunesManager { } const availableBlockTunes = new Map( - Array.from(toolsManager.blockTunes.entries()).map(([name, facade]) => [ - name, - facade.create({}, blockId, api), - ]) + 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({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3e06b3a6..18549388 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,7 +17,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/index.js'; +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'; diff --git a/packages/dom-adapters/jest.config.ts b/packages/dom-adapters/jest.config.ts index 77c0e2ac..427ccc78 100644 --- a/packages/dom-adapters/jest.config.ts +++ b/packages/dom-adapters/jest.config.ts @@ -19,4 +19,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..e2e2f573 --- /dev/null +++ b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts @@ -0,0 +1,184 @@ +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 + _api: any; + blockId: string = ''; + tuneName: string = ''; + _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()); + } + + setBlockId(id: string): void { this.blockId = id; } + setTuneName(name: string): void { this.tuneName = name; } + + 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 }); + } + + 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 }); + } + + 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 = () => { + 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/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 565498e5..e85dc5e6 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 - name of the tune + */ + 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 - name of the tune + * @param params.data - new tune data (merged into existing data) + * @param [params.userId] - user id + */ + 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 b894806a..23276020 100644 --- a/packages/sdk/src/entities/BlockTune.ts +++ b/packages/sdk/src/entities/BlockTune.ts @@ -7,6 +7,7 @@ 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). @@ -56,6 +57,11 @@ export interface BlockTuneConstructorOptions< * 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; } /** diff --git a/packages/sdk/src/entities/BlockTuneAdapter.ts b/packages/sdk/src/entities/BlockTuneAdapter.ts new file mode 100644 index 00000000..2a34684a --- /dev/null +++ b/packages/sdk/src/entities/BlockTuneAdapter.ts @@ -0,0 +1,129 @@ +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'; + +/** + * 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 - block id + */ + public setBlockId(id: BlockId): void { + this.blockId = id; + } + + /** + * Sets the tune name this adapter is bound to + * @param name - tune name + */ + 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 { value, previous } = event.detail.data as { value: unknown; previous: unknown }; + 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 event - model event + */ + protected abstract handleModelUpdate(event: ModelEvents): void; +} diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts index 2e6696aa..0fd82864 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 - name of the tune + */ + 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 - name of the tune + */ + 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/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..095f69cf --- /dev/null +++ b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts @@ -0,0 +1,38 @@ +import { AdapterEventType } from './AdapterEventType.js'; + +/** + * Payload of the TuneDataChanged event + */ +export interface TuneDataChangedPayload { + /** + * Changed tune data key + */ + 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 { + /** + * TuneDataChangedEvent constructor + * @param key - the data key of the tune entry that changed + * @param value - new value + * @param previous - previous value + */ + 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/index.ts b/packages/sdk/src/entities/index.ts index 12d13483..fc3a3e14 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/BlockTuneFacade.spec.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts index 838b31b4..57437763 100644 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts @@ -5,10 +5,12 @@ 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(); @@ -41,23 +43,24 @@ function createTuneFacade(): { facade: BlockTuneFacade; constructorSpy: jest.Moc describe('BlockTuneFacade', () => { describe('create()', () => { - it('passes data, blockId and api to the tune constructor', () => { + 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); + facade.create(tuneData, mockBlockId, mockApi, mockAdapter); expect(constructorSpy).toHaveBeenCalledWith(expect.objectContaining({ data: tuneData, blockId: mockBlockId, api: mockApi, + adapter: mockAdapter, })); }); - it('returns the tune instance created by the constructor', () => { + it('should return the tune instance created by the constructor', () => { const { facade } = createTuneFacade(); - const instance = facade.create({}, mockBlockId, mockApi); + 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 358b7c1c..ec7e47bb 100644 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.ts +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.ts @@ -3,6 +3,7 @@ import type { BlockTuneConstructor, BlockTune as IBlockTune, BlockTuneData } fro import { ToolType } from '../../entities'; import type { BlockId } from '@editorjs/model'; import type { EditorAPI } from '../../api/EditorAPI.js'; +import type { BlockTuneAdapter } from '../../entities/BlockTuneAdapter.js'; /** * Facade for BlockTune tools @@ -23,14 +24,16 @@ export class BlockTuneFacade extends BaseToolFacade { * @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, blockId: BlockId, api: EditorAPI): IBlockTune { + public create(data: BlockTuneData, blockId: BlockId, api: EditorAPI, adapter: BlockTuneAdapter): IBlockTune { return new this.constructable({ config: this.config, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data, api, blockId, + adapter, }); } } From a10005cee437abb2e7bb76b56003b51fd0a37897 Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Wed, 24 Jun 2026 23:10:00 +0200 Subject: [PATCH 6/7] fix lint errors --- packages/core/src/api/BlocksAPI.spec.ts | 18 +++++--- packages/core/src/api/BlocksAPI.ts | 7 ++- .../src/components/BlockTunesManager.spec.ts | 5 ++- .../core/src/components/BlockTunesManager.ts | 6 +++ .../tunes/internal/delete-block/index.spec.ts | 8 ++-- .../src/tunes/internal/delete-block/index.ts | 10 ++++- .../tunes/internal/move-down/index.spec.ts | 8 ++-- .../src/tunes/internal/move-down/index.ts | 13 +++++- .../src/tunes/internal/move-up/index.spec.ts | 8 ++-- .../core/src/tunes/internal/move-up/index.ts | 13 +++++- .../src/BlockTuneAdapter/index.spec.ts | 44 +++++++++++++------ packages/sdk/src/api/BlocksAPI.ts | 4 +- packages/sdk/src/entities/BlockTuneAdapter.ts | 7 +-- .../sdk/src/entities/EditorjsAdapterPlugin.ts | 4 +- .../events/adapter/TuneDataChanged.ts | 8 ++-- .../src/tools/facades/BlockTuneFacade.spec.ts | 8 ++-- 16 files changed, 122 insertions(+), 49 deletions(-) diff --git a/packages/core/src/api/BlocksAPI.spec.ts b/packages/core/src/api/BlocksAPI.spec.ts index bbf312eb..a3508afd 100644 --- a/packages/core/src/api/BlocksAPI.spec.ts +++ b/packages/core/src/api/BlocksAPI.spec.ts @@ -235,7 +235,8 @@ describe('BlocksAPI', () => { model ); - const result = api.getTuneData({ block: 0, tuneName: 'myTune' }); + const result = api.getTuneData({ block: 0, + tuneName: 'myTune' }); expect(result).toEqual(tuneData); expect(model.getBlockSerialized).toHaveBeenCalledWith(0); @@ -253,7 +254,8 @@ describe('BlocksAPI', () => { model ); - const result = api.getTuneData({ block: 0, tuneName: 'missingTune' }); + const result = api.getTuneData({ block: 0, + tuneName: 'missingTune' }); expect(result).toEqual({}); }); @@ -270,7 +272,8 @@ describe('BlocksAPI', () => { model ); - const result = api.getTuneData({ block: 0, tuneName: 'anyTune' }); + const result = api.getTuneData({ block: 0, + tuneName: 'anyTune' }); expect(result).toEqual({}); }); @@ -292,7 +295,9 @@ describe('BlocksAPI', () => { model ); - api.updateTuneData({ block: 1, tuneName: 'align', data: { align: 'center' } }); + api.updateTuneData({ block: 1, + tuneName: 'align', + data: { align: 'center' } }); expect(model.updateTuneData).toHaveBeenCalledWith('user1', 1, 'align', { align: 'center' }); }); @@ -312,7 +317,10 @@ describe('BlocksAPI', () => { model ); - api.updateTuneData({ block: 0, tuneName: 'align', data: {}, userId: 'overrideUser' }); + 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 afb5fdf4..713eb107 100644 --- a/packages/core/src/api/BlocksAPI.ts +++ b/packages/core/src/api/BlocksAPI.ts @@ -7,7 +7,6 @@ import { BlocksAPI as BlocksApiInterface } from '@editorjs/sdk'; import { BlockId, BlockIndexOrId, - BlockTuneName, createBlockId, createBlockTuneName, createDataKey, @@ -235,7 +234,7 @@ export class BlocksAPI implements BlocksApiInterface { * 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 - name of the tune + * @param params.tuneName - tune to read data from */ public getTuneData({ block, tuneName }: Parameters[0]): Record { return this.#model.getBlockSerialized(block as BlockIndexOrId).tunes?.[tuneName] ?? {}; @@ -245,11 +244,11 @@ export class BlocksAPI implements BlocksApiInterface { * 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 - name of the tune + * @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) as BlockTuneName, data); + this.#model.updateTuneData(userId, block as BlockIndexOrId, createBlockTuneName(tuneName), data); } } diff --git a/packages/core/src/components/BlockTunesManager.spec.ts b/packages/core/src/components/BlockTunesManager.spec.ts index bad0f76e..41e06533 100644 --- a/packages/core/src/components/BlockTunesManager.spec.ts +++ b/packages/core/src/components/BlockTunesManager.spec.ts @@ -1,6 +1,7 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ +/* 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; @@ -65,7 +66,7 @@ describe('BlockTunesManager', () => { new BlockTunesManager( eventBus, toolsManager, - mockApi as unknown as import('../api/index.js').EditorAPI, + mockApi as unknown as EditorAPI, mockAdapter as never ); diff --git a/packages/core/src/components/BlockTunesManager.ts b/packages/core/src/components/BlockTunesManager.ts index 87c34977..688e7b2d 100644 --- a/packages/core/src/components/BlockTunesManager.ts +++ b/packages/core/src/components/BlockTunesManager.ts @@ -16,6 +16,12 @@ import { TOKENS } from '../tokens.js'; */ @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, diff --git a/packages/core/src/tunes/internal/delete-block/index.spec.ts b/packages/core/src/tunes/internal/delete-block/index.spec.ts index 43a4ed08..72b5556a 100644 --- a/packages/core/src/tunes/internal/delete-block/index.spec.ts +++ b/packages/core/src/tunes/internal/delete-block/index.spec.ts @@ -1,6 +1,8 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ +/* 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' }, @@ -20,10 +22,10 @@ describe('DeleteBlockTune', () => { }, }; - const mockBlockId = 'block-id-456' as unknown as import('@editorjs/model').BlockId; + const mockBlockId = 'block-id-456' as unknown as BlockId; const tune = new DeleteBlockTune({ - api: mockApi as unknown as import('@editorjs/sdk').EditorAPI, + api: mockApi as unknown as EditorAPI, blockId: mockBlockId, data: undefined, config: {}, diff --git a/packages/core/src/tunes/internal/delete-block/index.ts b/packages/core/src/tunes/internal/delete-block/index.ts index eef808bf..b2877af2 100644 --- a/packages/core/src/tunes/internal/delete-block/index.ts +++ b/packages/core/src/tunes/internal/delete-block/index.ts @@ -8,7 +8,7 @@ import type { BlockId } from '@editorjs/model'; * Internal tune that deletes the current block */ export class DeleteBlockTune implements BlockTune { - public static readonly type = ToolType.Tune as const; + public static readonly type = ToolType.Tune; public static readonly name = 'deleteBlock'; public readonly title = 'Delete'; @@ -17,11 +17,19 @@ export class DeleteBlockTune implements BlockTune { #api: EditorAPI; #blockId: BlockId; + /** + * @param options - tune constructor options + * @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)); diff --git a/packages/core/src/tunes/internal/move-down/index.spec.ts b/packages/core/src/tunes/internal/move-down/index.spec.ts index 94112dc7..c3afda7b 100644 --- a/packages/core/src/tunes/internal/move-down/index.spec.ts +++ b/packages/core/src/tunes/internal/move-down/index.spec.ts @@ -1,6 +1,8 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ +/* 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' }, @@ -21,10 +23,10 @@ describe('MoveDownTune', () => { }, }; - const mockBlockId = 'block-id-789' as unknown as import('@editorjs/model').BlockId; + const mockBlockId = 'block-id-789' as unknown as BlockId; const tune = new MoveDownTune({ - api: mockApi as unknown as import('@editorjs/sdk').EditorAPI, + api: mockApi as unknown as EditorAPI, blockId: mockBlockId, data: undefined, config: {}, diff --git a/packages/core/src/tunes/internal/move-down/index.ts b/packages/core/src/tunes/internal/move-down/index.ts index 40cbb29f..e44376ed 100644 --- a/packages/core/src/tunes/internal/move-down/index.ts +++ b/packages/core/src/tunes/internal/move-down/index.ts @@ -8,7 +8,7 @@ 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 as const; + public static readonly type = ToolType.Tune; public static readonly name = 'moveDown'; public readonly title = 'Move down'; @@ -17,17 +17,28 @@ export class MoveDownTune implements BlockTune { #api: EditorAPI; #blockId: BlockId; + /** + * @param options - tune constructor options + * @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(); diff --git a/packages/core/src/tunes/internal/move-up/index.spec.ts b/packages/core/src/tunes/internal/move-up/index.spec.ts index b8adc261..b973333b 100644 --- a/packages/core/src/tunes/internal/move-up/index.spec.ts +++ b/packages/core/src/tunes/internal/move-up/index.spec.ts @@ -1,6 +1,8 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers, jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ +/* 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' }, @@ -20,10 +22,10 @@ describe('MoveUpTune', () => { }, }; - const mockBlockId = 'block-id-123' as unknown as import('@editorjs/model').BlockId; + const mockBlockId = 'block-id-123' as unknown as BlockId; const tune = new MoveUpTune({ - api: mockApi as unknown as import('@editorjs/sdk').EditorAPI, + api: mockApi as unknown as EditorAPI, blockId: mockBlockId, data: undefined, config: {}, diff --git a/packages/core/src/tunes/internal/move-up/index.ts b/packages/core/src/tunes/internal/move-up/index.ts index a85df089..3a692eb2 100644 --- a/packages/core/src/tunes/internal/move-up/index.ts +++ b/packages/core/src/tunes/internal/move-up/index.ts @@ -8,7 +8,7 @@ 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 as const; + public static readonly type = ToolType.Tune; public static readonly name = 'moveUp'; public readonly title = 'Move up'; @@ -17,15 +17,26 @@ export class MoveUpTune implements BlockTune { #api: EditorAPI; #blockId: BlockId; + /** + * @param options - tune constructor options + * @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)); diff --git a/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts index e2e2f573..d54443fb 100644 --- a/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts +++ b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + import { jest } from '@jest/globals'; import type { ModelEvents } from '@editorjs/model'; @@ -25,10 +27,10 @@ jest.mock('@editorjs/model', () => ({ jest.mock('@editorjs/sdk', () => { class FakeBlockTuneAdapter extends EventTarget { // eslint-disable-next-line @typescript-eslint/no-explicit-any - _api: any; - blockId: string = ''; - tuneName: string = ''; - _cleanup: (() => void) | null = null; + 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) { @@ -39,20 +41,28 @@ jest.mock('@editorjs/sdk', () => { this._cleanup = api.document.onUpdate(jest.fn()); } - setBlockId(id: string): void { this.blockId = id; } - setTuneName(name: string): void { this.tuneName = name; } + public setBlockId(id: string): void { + this.blockId = id; + } - getData(): Record { + 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 }); + return this._api.blocks.getTuneData({ block: this.blockId, + tuneName: this.tuneName }); } - setData(data: Record): void { + 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 }); + this._api.blocks.updateTuneData({ block: this.blockId, + tuneName: this.tuneName, + data }); } - destroy(): void { + public destroy(): void { if (this._cleanup) { this._cleanup(); } @@ -68,7 +78,14 @@ jest.mock('@editorjs/sdk', () => { import { DOMBlockTuneAdapter } from './index.js'; describe('DOMBlockTuneAdapter', () => { - const makeApi = () => { + 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); @@ -144,7 +161,8 @@ describe('DOMBlockTuneAdapter', () => { const result = adapter.getData(); - expect(api.blocks.getTuneData).toHaveBeenCalledWith({ block: 'block-1', tuneName: 'fontSize' }); + expect(api.blocks.getTuneData).toHaveBeenCalledWith({ block: 'block-1', + tuneName: 'fontSize' }); expect(result).toEqual({ result: true }); }); }); diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 9a4cf178..9d34f10d 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -219,7 +219,7 @@ export interface BlocksAPI { /** * Returns the serialized data for the given tune on a block * @param params.block - index or id of the block - * @param params.tuneName - name of the tune + * @param params.tuneName - tune to read data from */ getTuneData(params: { /** Index or id of the block */ @@ -231,7 +231,7 @@ export interface BlocksAPI { /** * Updates tune data for the given block and tune name * @param params.block - index or id of the block - * @param params.tuneName - name of the tune + * @param params.tuneName - tune to update data for * @param params.data - new tune data (merged into existing data) * @param [params.userId] - user id */ diff --git a/packages/sdk/src/entities/BlockTuneAdapter.ts b/packages/sdk/src/entities/BlockTuneAdapter.ts index 2a34684a..2f17db92 100644 --- a/packages/sdk/src/entities/BlockTuneAdapter.ts +++ b/packages/sdk/src/entities/BlockTuneAdapter.ts @@ -46,7 +46,7 @@ export abstract class BlockTuneAdapter extends EventTarget { /** * Sets the block id this adapter is bound to - * @param id - block id + * @param id - unique identifier of the block to bind to */ public setBlockId(id: BlockId): void { this.blockId = id; @@ -54,7 +54,7 @@ export abstract class BlockTuneAdapter extends EventTarget { /** * Sets the tune name this adapter is bound to - * @param name - tune name + * @param name - tune to bind to */ public setTuneName(name: string): void { this.tuneName = name; @@ -113,6 +113,7 @@ export abstract class BlockTuneAdapter extends EventTarget { return; } + // eslint-disable-next-line jsdoc/require-jsdoc const { value, previous } = event.detail.data as { value: unknown; previous: unknown }; const tuneKey = event.detail.index.tuneKey ?? ''; @@ -123,7 +124,7 @@ export abstract class BlockTuneAdapter extends EventTarget { /** * Hook for subclasses to react to model updates for their block/tune - * @param event - model event + * @param event - the model update event to handle */ protected abstract handleModelUpdate(event: ModelEvents): void; } diff --git a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts index 0fd82864..ff9e38e5 100644 --- a/packages/sdk/src/entities/EditorjsAdapterPlugin.ts +++ b/packages/sdk/src/entities/EditorjsAdapterPlugin.ts @@ -27,14 +27,14 @@ export interface EditorJSAdapterPlugin extends EditorjsPlugin { * 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 - name of the tune + * @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 - name of the tune + * @param tuneName - tune to look up the adapter for */ getBlockTuneAdapter(blockId: BlockId, tuneName: string): BlockTuneAdapter | undefined; diff --git a/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts index 095f69cf..49ec2a3c 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts @@ -5,7 +5,7 @@ import { AdapterEventType } from './AdapterEventType.js'; */ export interface TuneDataChangedPayload { /** - * Changed tune data key + * Data key whose value changed */ key: string; @@ -26,13 +26,15 @@ export interface TuneDataChangedPayload { export class TuneDataChangedEvent extends CustomEvent { /** * TuneDataChangedEvent constructor - * @param key - the data key of the tune entry that changed + * @param key - which data field changed * @param value - new value * @param previous - previous value */ constructor(key: string, value: unknown, previous: unknown) { super(AdapterEventType.TuneUpdated, { - detail: { key, value, previous }, + detail: { key, + value, + previous }, }); } } diff --git a/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts index 57437763..096582bb 100644 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts @@ -1,4 +1,4 @@ -/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/naming-convention */ +/* eslint-disable jsdoc/require-jsdoc */ import { describe, expect, it, jest } from '@jest/globals'; import { ToolType } from '../../entities/EntityType.js'; @@ -7,12 +7,14 @@ import type { BlockTuneConstructor, BlockTuneConstructorOptions } from '../../en import type { EditorAPI } from '../../api/EditorAPI.js'; import type { BlockTuneAdapter } from '../../entities/BlockTuneAdapter.js'; import type { BlockId } from '@editorjs/model'; +import type { API } from '@editorjs/editorjs'; const mockBlockId = 'test-block-id' as unknown as BlockId; const mockApi = {} as EditorAPI; const mockAdapter = {} as BlockTuneAdapter; -function createTuneFacade(): { facade: BlockTuneFacade; constructorSpy: jest.Mock } { +function createTuneFacade(): { facade: BlockTuneFacade; + constructorSpy: jest.Mock; } { const constructorSpy = jest.fn(); class MockTune { @@ -32,7 +34,7 @@ function createTuneFacade(): { facade: BlockTuneFacade; constructorSpy: jest.Moc name: 'mockTune', constructable: MockTune as unknown as BlockTuneConstructor, useToolOptions: {}, - api: {} as import('@editorjs/editorjs').API, + api: {} as API, isDefault: false, defaultPlaceholder: false, }); From 14e6a22fc692b751a9ffc6f80c408dcedc9a46cf Mon Sep 17 00:00:00 2001 From: Bettina Steger Date: Wed, 24 Jun 2026 23:20:31 +0200 Subject: [PATCH 7/7] fix specs --- .../tunes/internal/delete-block/index.spec.ts | 1 + .../src/tunes/internal/delete-block/index.ts | 2 +- .../tunes/internal/move-down/index.spec.ts | 1 + .../src/tunes/internal/move-down/index.ts | 2 +- .../src/tunes/internal/move-up/index.spec.ts | 1 + .../core/src/tunes/internal/move-up/index.ts | 2 +- .../src/BlockTuneAdapter/index.spec.ts | 3 ++- packages/sdk/src/api/BlocksAPI.ts | 2 +- packages/sdk/src/entities/BlockTuneAdapter.ts | 23 +++++++++++++++---- .../events/adapter/TuneDataChanged.ts | 4 ++-- .../src/tools/facades/BlockTuneFacade.spec.ts | 3 +-- 11 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tunes/internal/delete-block/index.spec.ts b/packages/core/src/tunes/internal/delete-block/index.spec.ts index 72b5556a..1eaa42c5 100644 --- a/packages/core/src/tunes/internal/delete-block/index.spec.ts +++ b/packages/core/src/tunes/internal/delete-block/index.spec.ts @@ -29,6 +29,7 @@ describe('DeleteBlockTune', () => { blockId: mockBlockId, data: undefined, config: {}, + adapter: {} as never, }); beforeEach(() => { diff --git a/packages/core/src/tunes/internal/delete-block/index.ts b/packages/core/src/tunes/internal/delete-block/index.ts index b2877af2..385c47c4 100644 --- a/packages/core/src/tunes/internal/delete-block/index.ts +++ b/packages/core/src/tunes/internal/delete-block/index.ts @@ -18,7 +18,7 @@ export class DeleteBlockTune implements BlockTune { #blockId: BlockId; /** - * @param options - tune constructor options + * @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 */ diff --git a/packages/core/src/tunes/internal/move-down/index.spec.ts b/packages/core/src/tunes/internal/move-down/index.spec.ts index c3afda7b..83ab8ad6 100644 --- a/packages/core/src/tunes/internal/move-down/index.spec.ts +++ b/packages/core/src/tunes/internal/move-down/index.spec.ts @@ -30,6 +30,7 @@ describe('MoveDownTune', () => { blockId: mockBlockId, data: undefined, config: {}, + adapter: {} as never, }); beforeEach(() => { diff --git a/packages/core/src/tunes/internal/move-down/index.ts b/packages/core/src/tunes/internal/move-down/index.ts index e44376ed..e17a21fa 100644 --- a/packages/core/src/tunes/internal/move-down/index.ts +++ b/packages/core/src/tunes/internal/move-down/index.ts @@ -18,7 +18,7 @@ export class MoveDownTune implements BlockTune { #blockId: BlockId; /** - * @param options - tune constructor options + * @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 */ diff --git a/packages/core/src/tunes/internal/move-up/index.spec.ts b/packages/core/src/tunes/internal/move-up/index.spec.ts index b973333b..df0cef30 100644 --- a/packages/core/src/tunes/internal/move-up/index.spec.ts +++ b/packages/core/src/tunes/internal/move-up/index.spec.ts @@ -29,6 +29,7 @@ describe('MoveUpTune', () => { blockId: mockBlockId, data: undefined, config: {}, + adapter: {} as never, }); beforeEach(() => { diff --git a/packages/core/src/tunes/internal/move-up/index.ts b/packages/core/src/tunes/internal/move-up/index.ts index 3a692eb2..6fc43769 100644 --- a/packages/core/src/tunes/internal/move-up/index.ts +++ b/packages/core/src/tunes/internal/move-up/index.ts @@ -18,7 +18,7 @@ export class MoveUpTune implements BlockTune { #blockId: BlockId; /** - * @param options - tune constructor options + * @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 */ diff --git a/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts index d54443fb..a8a43993 100644 --- a/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts +++ b/packages/dom-adapters/src/BlockTuneAdapter/index.spec.ts @@ -80,7 +80,8 @@ import { DOMBlockTuneAdapter } from './index.js'; describe('DOMBlockTuneAdapter', () => { const makeApi = (): { api: { - blocks: { getTuneData: ReturnType; updateTuneData: ReturnType }; + blocks: { getTuneData: ReturnType; + updateTuneData: ReturnType; }; document: { onUpdate: ReturnType }; }; cleanupFn: ReturnType; diff --git a/packages/sdk/src/api/BlocksAPI.ts b/packages/sdk/src/api/BlocksAPI.ts index 9d34f10d..7a8caa5a 100644 --- a/packages/sdk/src/api/BlocksAPI.ts +++ b/packages/sdk/src/api/BlocksAPI.ts @@ -233,7 +233,7 @@ export interface BlocksAPI { * @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] - user id + * @param [params.userId] - defaults to the current user id from the config */ updateTuneData(params: { /** Index or id of the block */ diff --git a/packages/sdk/src/entities/BlockTuneAdapter.ts b/packages/sdk/src/entities/BlockTuneAdapter.ts index 2f17db92..262954a8 100644 --- a/packages/sdk/src/entities/BlockTuneAdapter.ts +++ b/packages/sdk/src/entities/BlockTuneAdapter.ts @@ -3,6 +3,21 @@ 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. @@ -113,8 +128,8 @@ export abstract class BlockTuneAdapter extends EventTarget { return; } - // eslint-disable-next-line jsdoc/require-jsdoc - const { value, previous } = event.detail.data as { value: unknown; previous: unknown }; + const tuneDataChange = event.detail.data as TuneDataChangePayload; + const { value, previous } = tuneDataChange; const tuneKey = event.detail.index.tuneKey ?? ''; this.dispatchEvent(new TuneDataChangedEvent(tuneKey, value, previous)); @@ -124,7 +139,7 @@ export abstract class BlockTuneAdapter extends EventTarget { /** * Hook for subclasses to react to model updates for their block/tune - * @param event - the model update event to handle + * @param eventDetails - carries the changed model data */ - protected abstract handleModelUpdate(event: ModelEvents): void; + protected abstract handleModelUpdate(eventDetails: ModelEvents): void; } diff --git a/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts index 49ec2a3c..b838b039 100644 --- a/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts +++ b/packages/sdk/src/entities/EventBus/events/adapter/TuneDataChanged.ts @@ -25,10 +25,10 @@ export interface TuneDataChangedPayload { */ export class TuneDataChangedEvent extends CustomEvent { /** - * TuneDataChangedEvent constructor + * Creates a tune data change event for the given key * @param key - which data field changed * @param value - new value - * @param previous - previous value + * @param previous - value before the change */ constructor(key: string, value: unknown, previous: unknown) { super(AdapterEventType.TuneUpdated, { diff --git a/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts index 096582bb..b4f46091 100644 --- a/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts +++ b/packages/sdk/src/tools/facades/BlockTuneFacade.spec.ts @@ -7,7 +7,6 @@ import type { BlockTuneConstructor, BlockTuneConstructorOptions } from '../../en import type { EditorAPI } from '../../api/EditorAPI.js'; import type { BlockTuneAdapter } from '../../entities/BlockTuneAdapter.js'; import type { BlockId } from '@editorjs/model'; -import type { API } from '@editorjs/editorjs'; const mockBlockId = 'test-block-id' as unknown as BlockId; const mockApi = {} as EditorAPI; @@ -34,7 +33,7 @@ function createTuneFacade(): { facade: BlockTuneFacade; name: 'mockTune', constructable: MockTune as unknown as BlockTuneConstructor, useToolOptions: {}, - api: {} as API, + api: {} as EditorAPI, isDefault: false, defaultPlaceholder: false, });